diff --git a/web/BUILD.bazel b/web/BUILD.bazel
index 2717a9c67..a77a14e67 100644
--- a/web/BUILD.bazel
+++ b/web/BUILD.bazel
@@ -15,6 +15,9 @@ test_suite(
"//web/package_test:pack_contents_test",
"//web/submitqueue:test",
"//web/submitqueue:typecheck_typecheck_test",
+ "//web/service/submitqueue:lint",
+ "//web/service/submitqueue:test",
+ "//web/service/submitqueue:typecheck_typecheck_test",
],
)
diff --git a/web/service/submitqueue/BUILD.bazel b/web/service/submitqueue/BUILD.bazel
index 478742345..71954400a 100644
--- a/web/service/submitqueue/BUILD.bazel
+++ b/web/service/submitqueue/BUILD.bazel
@@ -1,3 +1,183 @@
+load("@aspect_rules_js//contrib/nextjs:defs.bzl", "nextjs_standalone_build", "nextjs_standalone_server")
+load("@aspect_rules_js//js:defs.bzl", "js_image_layer")
+load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("@npm//web/service/submitqueue:eslint/package_json.bzl", eslint = "bin")
+load("@npm//web/service/submitqueue:next/package_json.bzl", next = "bin")
+load("@npm//web/service/submitqueue:vitest/package_json.bzl", vitest = "bin")
+load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load")
+
package(default_visibility = ["//visibility:public"])
-exports_files(["package.json"])
+npm_link_all_packages(name = "node_modules")
+
+ts_config(
+ name = "tsconfig",
+ src = "tsconfig.json",
+)
+
+ts_project(
+ name = "typecheck",
+ srcs = glob([
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ ]) + [
+ "next-env.d.ts",
+ "next.config.ts",
+ "vitest.config.ts",
+ ],
+ declaration = False,
+ incremental = True,
+ no_emit = True,
+ tsconfig = ":tsconfig",
+ deps = [
+ ":node_modules/@bufbuild/protobuf",
+ ":node_modules/@connectrpc/connect",
+ ":node_modules/@connectrpc/connect-node",
+ ":node_modules/@submitqueue/api",
+ ":node_modules/@submitqueue/web-submitqueue",
+ ":node_modules/@types/node",
+ ":node_modules/@types/react",
+ ":node_modules/@types/react-dom",
+ ":node_modules/next",
+ ":node_modules/react",
+ ":node_modules/react-dom",
+ ":node_modules/server-only",
+ ":node_modules/vitest",
+ ],
+)
+
+eslint.eslint_test(
+ name = "lint",
+ args = ["."],
+ chdir = package_name(),
+ data = [
+ ":node_modules",
+ ":node_modules/eslint",
+ ":node_modules/eslint-config-next",
+ "//web:node_modules/next",
+ "eslint.config.mjs",
+ "next-env.d.ts",
+ "next.config.ts",
+ "package.json",
+ "tsconfig.json",
+ "vitest.config.ts",
+ ] + glob([
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "src/**/*.css",
+ ]),
+)
+
+vitest.vitest_test(
+ name = "test",
+ args = [
+ "run",
+ "--config",
+ "vitest.config.ts",
+ ],
+ chdir = package_name(),
+ data = [
+ ":node_modules",
+ ":node_modules/vitest",
+ "//web:node_modules/@bufbuild/protobuf",
+ "//web:node_modules/@connectrpc/connect",
+ "//web:node_modules/react",
+ "//web:node_modules/react-dom",
+ "//web/api:pkg",
+ "//web/submitqueue:pkg",
+ "//web:node_modules/vitest",
+ "vitest.config.ts",
+ ] + glob([
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ ]),
+)
+
+next.next_binary(
+ name = "next",
+)
+
+nextjs_standalone_build(
+ name = "standalone",
+ args = ["--webpack"],
+ config = "next.config.ts",
+ data = [
+ ":node_modules",
+ ":node_modules/@bufbuild/protobuf",
+ ":node_modules/@connectrpc/connect",
+ ":node_modules/@connectrpc/connect-node",
+ ":node_modules/@submitqueue/api",
+ ":node_modules/@submitqueue/web-submitqueue",
+ ":node_modules/next",
+ ":node_modules/react",
+ ":node_modules/react-dom",
+ ":node_modules/server-only",
+ "//web/api:lib",
+ "//web/api:lib_types",
+ "//web:node_modules/@bufbuild/protobuf",
+ "//web:node_modules/@connectrpc/connect",
+ "//web:node_modules/next",
+ "//web:node_modules/react",
+ "//web:node_modules/react-dom",
+ "//web/submitqueue:lib",
+ "//web/submitqueue:lib_types",
+ "package.json",
+ "tsconfig.json",
+ ],
+ env = {"NEXT_TELEMETRY_DISABLED": "1"},
+ next_js_binary = ":next",
+ srcs = ["next-env.d.ts"] + glob([
+ "src/**",
+ ]),
+)
+
+nextjs_standalone_server(
+ name = "server",
+ app = ":standalone",
+ data = [
+ ":node_modules/@bufbuild/protobuf",
+ ":node_modules/@connectrpc/connect",
+ ":node_modules/@connectrpc/connect-node",
+ ":node_modules/next",
+ ":node_modules/react",
+ ":node_modules/react-dom",
+ ":node_modules/server-only",
+ ],
+ pkg = "service/submitqueue",
+)
+
+platform(
+ name = "linux_amd64",
+ constraint_values = [
+ "@platforms//cpu:x86_64",
+ "@platforms//os:linux",
+ ],
+)
+
+js_image_layer(
+ name = "server_layers",
+ binary = ":server",
+ platform = ":linux_amd64",
+ root = "/app",
+)
+
+oci_image(
+ name = "image",
+ base = "@node_22_20_0_bookworm_slim_linux_amd64",
+ entrypoint = ["/app/web/service/submitqueue/server"],
+ env = {
+ "HOSTNAME": "0.0.0.0",
+ "NODE_ENV": "production",
+ "PORT": "3000",
+ },
+ tars = [":server_layers"],
+ user = "node",
+ workdir = "/app/web/service/submitqueue/server.runfiles/_main",
+)
+
+oci_load(
+ name = "image_load",
+ image = ":image",
+ repo_tags = ["submitqueue-web-service:latest"],
+)
diff --git a/web/service/submitqueue/README.md b/web/service/submitqueue/README.md
new file mode 100644
index 000000000..f70d5c3d9
--- /dev/null
+++ b/web/service/submitqueue/README.md
@@ -0,0 +1,7 @@
+# SubmitQueue web reference host
+
+The Next.js demo host owns HTTP Basic authentication, gateway transport, dynamic rendering, readiness, refresh integration, and deployment. Page routes are added in the following stack changes.
+
+Set `SUBMITQUEUE_WEB_TOKEN` to a nonempty password for username `test`, configure `SUBMITQUEUE_GATEWAY_URL`, and explicitly enable `SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT=true` only for local h2c. The local demo uses password `test`.
+
+`/healthz` validates configuration and pings the gateway. Bazel targets `:standalone`, `:server`, `:image`, and `:image_load` build and package the host; there is no web Dockerfile.
diff --git a/web/service/submitqueue/eslint.config.mjs b/web/service/submitqueue/eslint.config.mjs
new file mode 100644
index 000000000..db21edbe5
--- /dev/null
+++ b/web/service/submitqueue/eslint.config.mjs
@@ -0,0 +1,7 @@
+import { defineConfig, globalIgnores } from "eslint/config";
+import nextVitals from "eslint-config-next/core-web-vitals";
+
+export default defineConfig([
+ ...nextVitals,
+ globalIgnores([".next/**", "node_modules/**"]),
+]);
diff --git a/web/service/submitqueue/next-env.d.ts b/web/service/submitqueue/next-env.d.ts
new file mode 100644
index 000000000..ce4e94a6b
--- /dev/null
+++ b/web/service/submitqueue/next-env.d.ts
@@ -0,0 +1,7 @@
+///
+///
+import "./.next/types/routes.d.ts";
+import "./.next/types/root-params.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/web/service/submitqueue/next.config.ts b/web/service/submitqueue/next.config.ts
new file mode 100644
index 000000000..84da88997
--- /dev/null
+++ b/web/service/submitqueue/next.config.ts
@@ -0,0 +1,16 @@
+import type { NextConfig } from "next";
+import path from "node:path";
+
+const nextConfig: NextConfig = {
+ experimental: {
+ authInterrupts: true,
+ },
+ output: "standalone",
+ outputFileTracingRoot: path.join(import.meta.dirname, "../.."),
+ outputFileTracingExcludes: {
+ "*": ["**/node_modules/**"],
+ },
+ transpilePackages: ["@submitqueue/api", "@submitqueue/web-submitqueue"],
+};
+
+export default nextConfig;
diff --git a/web/service/submitqueue/package.json b/web/service/submitqueue/package.json
index 1d8184471..545fbb8d7 100644
--- a/web/service/submitqueue/package.json
+++ b/web/service/submitqueue/package.json
@@ -3,7 +3,14 @@
"version": "0.0.0",
"private": true,
"type": "module",
- "scripts": {},
+ "scripts": {
+ "build": "next build",
+ "dev": "next dev --hostname 0.0.0.0 --port 3000",
+ "lint": "eslint .",
+ "start": "next start --hostname 0.0.0.0 --port 3000",
+ "test": "vitest run",
+ "typecheck": "tsc --noEmit"
+ },
"dependencies": {
"@bufbuild/protobuf": "2.15.0",
"@connectrpc/connect": "^2.1.1",
diff --git a/web/service/submitqueue/public/robots.txt b/web/service/submitqueue/public/robots.txt
new file mode 100644
index 000000000..eb0536286
--- /dev/null
+++ b/web/service/submitqueue/public/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow:
diff --git a/web/service/submitqueue/src/app/(protected)/layout.tsx b/web/service/submitqueue/src/app/(protected)/layout.tsx
new file mode 100644
index 000000000..5b485b2d2
--- /dev/null
+++ b/web/service/submitqueue/src/app/(protected)/layout.tsx
@@ -0,0 +1,31 @@
+import type { ReactNode } from "react";
+import Link from "next/link";
+
+import { requireAuthorization } from "../../server/request-auth";
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+
+export default async function ProtectedLayout({
+ children,
+}: Readonly<{ children: ReactNode }>) {
+ await requireAuthorization();
+
+ return (
+
+
+
+
+ SQ
+
+ SubmitQueue
+
+ Local demo · read-only
+
+ {children}
+
+
+ );
+}
diff --git a/web/service/submitqueue/src/app/(protected)/page.tsx b/web/service/submitqueue/src/app/(protected)/page.tsx
new file mode 100644
index 000000000..49cf35e51
--- /dev/null
+++ b/web/service/submitqueue/src/app/(protected)/page.tsx
@@ -0,0 +1,11 @@
+import { connection } from "next/server";
+import { requireAuthorization } from "../../server/request-auth";
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+
+export default async function HomePage() {
+ await connection();
+ await requireAuthorization();
+ return SubmitQueue
Read-only demo host
;
+}
diff --git a/web/service/submitqueue/src/app/globals.css b/web/service/submitqueue/src/app/globals.css
new file mode 100644
index 000000000..dd95900de
--- /dev/null
+++ b/web/service/submitqueue/src/app/globals.css
@@ -0,0 +1,110 @@
+:root {
+ color-scheme: light;
+ --surface: #fff;
+ --surface-muted: #f5f6f8;
+ --text: #202327;
+ --muted: #626a74;
+ --border: #e3e6e9;
+ --accent: #175fc5;
+ --success: #187247;
+ --warning: #8b590f;
+ --danger: #b33535;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+* { box-sizing: border-box; }
+body { margin: 0; background: var(--surface); color: var(--text); font-size: 14px; line-height: 1.5; }
+a { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
+a:hover { text-decoration: underline; }
+a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible {
+ outline: 3px solid var(--accent); outline-offset: 3px;
+}
+h1 { font-size: 26px; line-height: 1.25; margin: 0 0 10px; letter-spacing: -.5px; }
+h2 { font-size: 16px; margin: 0 0 12px; }
+p { margin: 8px 0; }
+button, input, select {
+ font: inherit; border: 1px solid var(--border); border-radius: 6px;
+ color: var(--text); background: var(--surface); padding: 8px 12px;
+}
+button, summary { cursor: pointer; }
+button:disabled { cursor: default; opacity: .65; }
+button:hover:not(:disabled) { background: var(--surface-muted); }
+input { min-width: 0; }
+.site-frame { min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; }
+.site-header, .shell, .site-footer { width: min(1200px, calc(100% - 48px)); margin-inline: auto; }
+.site-header { min-height: 80px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--border); }
+.brand { display: flex; align-items: center; gap: 9px; color: var(--text); font-weight: 600; font-size: 18px; }
+.brand-mark { background: var(--text); color: var(--surface); border-radius: 6px; padding: 5px 7px; font-size: 12px; }
+.environment-pill, .site-footer, .sq-secondary { color: var(--muted); font-size: 12px; }
+.site-footer { padding: 20px 0; border-top: 1px solid var(--border); }
+.shell { padding-block: 28px 48px; }
+.centered-state { min-height: 100vh; display: grid; place-items: center; }
+.page-heading, .sq-detail-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-bottom: 24px; }
+.page-heading p { color: var(--muted); font-size: 12px; }
+.eyebrow { font-size: 12px; margin: 0 0 10px; color: var(--muted); }
+.page-actions, .sq-detail-actions, .sq-copy { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.sq-detail-actions { align-items: flex-start; }
+.sq-detail-actions button { min-height: 40px; font-size: 14px; }
+.sq-copy [role="status"] { width: 100%; color: var(--muted); font-size: 12px; }
+.sq-copy [role="status"]:empty { display: none; }
+.page-actions { margin-top: 12px; }
+.sq-refresh { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; }
+.sq-toolbar { display: flex; justify-content: space-between; gap: 14px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; }
+.sq-toolbar label { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; flex-wrap: wrap; }
+.sq-toolbar input { width: 240px; max-width: 100%; }
+.sq-table-scroll { overflow-x: auto; }
+table { width: 100%; border-collapse: collapse; text-align: left; }
+th { font-weight: 500; color: var(--muted); font-size: 12px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
+td { padding: 18px 12px; border-bottom: 1px solid var(--border); vertical-align: top; }
+th:first-child, td:first-child { padding-left: 0; }
+th:last-child, td:last-child { padding-right: 0; }
+td time { font-size: 12px; color: var(--muted); }
+.sq-table-footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 18px; font-size: 12px; color: var(--muted); }
+.sq-changes { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
+.sq-changes code, .sq-changes a, .sq-version { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
+.sq-status, .sq-event { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: 12px; color: var(--accent); background: #edf3ff; white-space: nowrap; }
+.sq-status[data-tone="neutral"] { color: var(--muted); background: var(--surface-muted); }
+.sq-status[data-tone="success"] { color: var(--success); background: #edf8f1; }
+.sq-status[data-tone="danger"] { color: var(--danger); background: #fff0ef; }
+.sq-status[data-tone="warning"] { color: var(--warning); background: #fff5e7; }
+.sq-row-error { color: var(--danger); font-size: 12px; max-width: 36ch; overflow-wrap: anywhere; }
+.sq-empty { padding: 35px 0; color: var(--muted); }
+.sq-breadcrumb { display: flex; gap: 10px; flex-wrap: wrap; color: var(--muted); font-size: 12px; margin-bottom: 24px; }
+.sq-tabs { display: flex; gap: 25px; border-bottom: 1px solid var(--border); margin-bottom: 24px; }
+.sq-tabs a { padding: 10px 0 12px; color: var(--muted); }
+.sq-tabs [aria-current="page"] { color: var(--text); border-bottom: 2px solid var(--text); }
+.sq-detail-columns { display: grid; grid-template-columns: 1fr 1.15fr; gap: 38px; }
+.sq-facts { margin: 0; }
+.sq-facts > div { padding: 11px 0; border-bottom: 1px solid var(--border); }
+dt { color: var(--muted); font-size: 12px; margin-bottom: 5px; }
+dd { margin: 0; overflow-wrap: anywhere; }
+.sq-soft-panel { padding: 18px; background: var(--surface-muted); border-radius: 8px; margin-bottom: 16px; }
+.sq-soft-panel summary { margin-bottom: 10px; }
+.sq-stage { list-style: none; display: flex; flex-wrap: wrap; gap: 9px; padding: 0; margin-top: 24px; color: var(--muted); font-size: 12px; }
+.sq-stage li + li::before { content: "›"; margin-right: 9px; }
+.sq-metadata { margin: 12px 0 0; }
+.sq-metadata__entry { display: grid; grid-template-columns: minmax(100px, .35fr) 1fr; gap: 16px; font-size: 12px; padding: 7px 0; }
+.sq-history { padding: 0; list-style: none; }
+.sq-history__item { display: grid; grid-template-columns: 220px 1fr auto; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border); }
+.sq-history__item time { font-size: 12px; color: var(--muted); }
+.sq-history__item details[open] .sq-metadata { background: var(--surface-muted); padding: 12px; border-radius: 6px; }
+.sq-history__item .sq-row-error { grid-column: 2; }
+.sq-failure-banner, .sq-error { padding: 16px; border-radius: 6px; background: #fff0ef; }
+.sq-failure-banner { color: var(--danger); }
+.sq-error p { overflow-wrap: anywhere; }
+.sq-directory ul { list-style: none; padding: 0; margin-top: 24px; }
+.sq-directory li { padding: 18px 0; border-bottom: 1px solid var(--border); }
+.sq-directory li p { color: var(--muted); }
+.sq-change-identity { display: flex; gap: 28px; flex-wrap: wrap; padding: 18px; border-radius: 8px; background: var(--surface-muted); margin: 24px 0; }
+.button-link { display: inline-block; margin-top: 16px; }
+.state-card { max-width: 700px; }
+@media (max-width: 680px) {
+ .site-header, .shell, .site-footer { width: calc(100% - 32px); }
+ .site-header { gap: 12px; flex-wrap: wrap; padding-block: 14px; }
+ .sq-detail-columns { grid-template-columns: 1fr; gap: 20px; }
+ .sq-history__item { grid-template-columns: 1fr; gap: 8px; }
+ .sq-history__item .sq-row-error { grid-column: 1; }
+ .sq-metadata__entry { grid-template-columns: 1fr; gap: 4px; }
+ .sq-toolbar label { align-items: flex-start; }
+ button, select, input { min-height: 44px; }
+ .sq-detail-actions button { min-height: 44px; }
+}
diff --git a/web/service/submitqueue/src/app/healthz/route.test.ts b/web/service/submitqueue/src/app/healthz/route.test.ts
new file mode 100644
index 000000000..57555012c
--- /dev/null
+++ b/web/service/submitqueue/src/app/healthz/route.test.ts
@@ -0,0 +1,80 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const { pingDemoGateway } = vi.hoisted(() => ({
+ pingDemoGateway: vi.fn(),
+}));
+
+vi.mock("../../server/gateway", () => ({ pingDemoGateway }));
+
+import { GET } from "./route";
+
+describe("GET /healthz", () => {
+ beforeEach(() => {
+ pingDemoGateway.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ pingDemoGateway.mockReset();
+ vi.restoreAllMocks();
+ vi.unstubAllEnvs();
+ });
+
+ it("is ready when required configuration is valid", async () => {
+ vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", "a".repeat(32));
+ vi.stubEnv("SUBMITQUEUE_GATEWAY_URL", "https://gateway.example:8080");
+ const response = await GET();
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ await expect(response.json()).resolves.toEqual({ status: "ok" });
+ });
+
+ it.each([
+ ["an empty token", "", "https://gateway.example:8080"],
+ ["a missing gateway", "a".repeat(32), ""],
+ ])("is unavailable with %s", async (_name, token, gatewayURL) => {
+ vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", token);
+ vi.stubEnv("SUBMITQUEUE_GATEWAY_URL", gatewayURL);
+ const response = await GET();
+
+ expect(response.status).toBe(503);
+ expect(response.headers.get("cache-control")).toBe("no-store");
+ await expect(response.json()).resolves.toEqual({ status: "unavailable" });
+ });
+
+ it("logs one actionable diagnostic for a repeated configuration failure", async () => {
+ vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", "a".repeat(32));
+ vi.stubEnv("SUBMITQUEUE_GATEWAY_URL", "https://gateway.example:8080");
+ await GET();
+
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", "");
+ await GET();
+ await GET();
+
+ expect(error).toHaveBeenCalledOnce();
+ expect(error).toHaveBeenCalledWith(
+ "SubmitQueue web configuration is invalid",
+ { message: "SUBMITQUEUE_WEB_TOKEN must be set" },
+ );
+ });
+
+ it("is unavailable and logs once while the gateway is unreachable", async () => {
+ vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", "a".repeat(32));
+ vi.stubEnv("SUBMITQUEUE_GATEWAY_URL", "https://gateway.example:8080");
+ const cause = new Error("connection refused");
+ pingDemoGateway.mockRejectedValue(cause);
+ const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ const first = await GET();
+ const second = await GET();
+
+ expect(first.status).toBe(503);
+ expect(second.status).toBe(503);
+ expect(error).toHaveBeenCalledOnce();
+ expect(error).toHaveBeenCalledWith(
+ "SubmitQueue web gateway readiness check failed",
+ { cause },
+ );
+ });
+});
diff --git a/web/service/submitqueue/src/app/healthz/route.ts b/web/service/submitqueue/src/app/healthz/route.ts
new file mode 100644
index 000000000..5869b9753
--- /dev/null
+++ b/web/service/submitqueue/src/app/healthz/route.ts
@@ -0,0 +1,50 @@
+import { loadAuthConfiguration } from "../../server/auth";
+import { loadGatewayConfiguration } from "../../server/config";
+import { pingDemoGateway } from "../../server/gateway";
+
+export const dynamic = "force-dynamic";
+
+let lastConfigurationFailure: string | null = null;
+let gatewayFailureReported = false;
+
+function reportConfigurationFailure(cause: unknown): void {
+ const message =
+ cause instanceof Error ? cause.message : "Unknown configuration failure";
+ if (message === lastConfigurationFailure) {
+ return;
+ }
+ lastConfigurationFailure = message;
+ console.error("SubmitQueue web configuration is invalid", { message });
+}
+
+export async function GET(): Promise {
+ try {
+ loadAuthConfiguration();
+ loadGatewayConfiguration();
+ lastConfigurationFailure = null;
+ } catch (cause) {
+ reportConfigurationFailure(cause);
+ return Response.json(
+ { status: "unavailable" },
+ { status: 503, headers: { "Cache-Control": "no-store" } },
+ );
+ }
+
+ try {
+ await pingDemoGateway();
+ gatewayFailureReported = false;
+ } catch (cause) {
+ if (!gatewayFailureReported) {
+ gatewayFailureReported = true;
+ console.error("SubmitQueue web gateway readiness check failed", { cause });
+ }
+ return Response.json(
+ { status: "unavailable" },
+ { status: 503, headers: { "Cache-Control": "no-store" } },
+ );
+ }
+ return Response.json(
+ { status: "ok" },
+ { headers: { "Cache-Control": "no-store" } },
+ );
+}
diff --git a/web/service/submitqueue/src/app/layout.tsx b/web/service/submitqueue/src/app/layout.tsx
new file mode 100644
index 000000000..239735840
--- /dev/null
+++ b/web/service/submitqueue/src/app/layout.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from "next";
+import type { ReactNode } from "react";
+
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: {
+ default: "SubmitQueue",
+ template: "%s · SubmitQueue",
+ },
+ description: "Read-only request status for the SubmitQueue demo queue.",
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/service/submitqueue/src/app/not-found.tsx b/web/service/submitqueue/src/app/not-found.tsx
new file mode 100644
index 000000000..49e5178ab
--- /dev/null
+++ b/web/service/submitqueue/src/app/not-found.tsx
@@ -0,0 +1,16 @@
+import Link from "next/link";
+
+export default function NotFound() {
+ return (
+
+
+ Request lookup
+ Request not found
+ The request may not exist in the demo queue or may have expired.
+
+ Return to queues
+
+
+
+ );
+}
diff --git a/web/service/submitqueue/src/app/unauthorized.tsx b/web/service/submitqueue/src/app/unauthorized.tsx
new file mode 100644
index 000000000..cfd1a9293
--- /dev/null
+++ b/web/service/submitqueue/src/app/unauthorized.tsx
@@ -0,0 +1,11 @@
+export default function Unauthorized() {
+ return (
+
+
+ SubmitQueue
+ Authentication required
+ Use the demo credentials configured for this host.
+
+
+ );
+}
diff --git a/web/service/submitqueue/src/components/next-refresh.tsx b/web/service/submitqueue/src/components/next-refresh.tsx
new file mode 100644
index 000000000..b15498819
--- /dev/null
+++ b/web/service/submitqueue/src/components/next-refresh.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { AutoRefresh, type AutoRefreshProps } from "@submitqueue/web-submitqueue";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useRef, useTransition } from "react";
+
+export function NextRefresh({ refreshHref, ...props }: Omit & { refreshHref?: string }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const completion = useRef<(() => void) | null>(null);
+ const transitionStarted = useRef(false);
+ const refresh = useCallback(() => new Promise((resolve) => {
+ completion.current = resolve;
+ transitionStarted.current = false;
+ startTransition(() => refreshHref ? router.replace(refreshHref) : router.refresh());
+ }), [router, startTransition, refreshHref]);
+
+ useEffect(() => {
+ if (!completion.current) {
+ return;
+ }
+ if (pending) {
+ transitionStarted.current = true;
+ } else if (transitionStarted.current) {
+ completion.current();
+ completion.current = null;
+ transitionStarted.current = false;
+ }
+ }, [pending]);
+
+ useEffect(() => () => {
+ completion.current?.();
+ completion.current = null;
+ }, []);
+
+ return ;
+}
diff --git a/web/service/submitqueue/src/proxy.test.ts b/web/service/submitqueue/src/proxy.test.ts
new file mode 100644
index 000000000..b33ba426f
--- /dev/null
+++ b/web/service/submitqueue/src/proxy.test.ts
@@ -0,0 +1,46 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { BASIC_AUTH_USERNAME } from "./server/auth";
+import { proxy } from "./proxy";
+
+const token = "demo-token-with-at-least-thirty-two-characters";
+
+function request(authorization?: string) {
+ return {
+ headers: new Headers(
+ authorization ? { authorization } : undefined,
+ ),
+ } as Parameters[0];
+}
+
+function basic(username: string, password: string): string {
+ return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
+}
+
+describe("proxy", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("challenges an anonymous browser", () => {
+ vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", token);
+ const response = proxy(request());
+
+ expect(response?.status).toBe(401);
+ expect(response?.headers.get("www-authenticate")).toMatch(/^Basic /u);
+ expect(response?.headers.get("cache-control")).toBe("no-store");
+ });
+
+ it("allows valid demo credentials", () => {
+ vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", token);
+ expect(proxy(request(basic(BASIC_AUTH_USERNAME, token))).status).toBe(200);
+ });
+
+ it("challenges an incorrect password", () => {
+ vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", token);
+ expect(
+ proxy(request(basic(BASIC_AUTH_USERNAME, "incorrect-password-value-000000")))
+ ?.status,
+ ).toBe(401);
+ });
+});
diff --git a/web/service/submitqueue/src/proxy.ts b/web/service/submitqueue/src/proxy.ts
new file mode 100644
index 000000000..240be2e25
--- /dev/null
+++ b/web/service/submitqueue/src/proxy.ts
@@ -0,0 +1,21 @@
+import { NextResponse, type NextRequest } from "next/server";
+
+import {
+ authenticationChallenge,
+ isAuthorized,
+ loadAuthConfiguration,
+} from "./server/auth";
+
+export function proxy(request: NextRequest): Response {
+ const configuration = loadAuthConfiguration();
+ if (!isAuthorized(request.headers.get("authorization"), configuration)) {
+ return authenticationChallenge();
+ }
+ return NextResponse.next();
+}
+
+export const config = {
+ matcher: [
+ "/((?!healthz$|_next/static|_next/image|favicon.ico|robots.txt).*)",
+ ],
+};
diff --git a/web/service/submitqueue/src/server/auth.test.ts b/web/service/submitqueue/src/server/auth.test.ts
new file mode 100644
index 000000000..c7bfd20a9
--- /dev/null
+++ b/web/service/submitqueue/src/server/auth.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ BASIC_AUTH_USERNAME,
+ isAuthorized,
+ loadAuthConfiguration,
+} from "./auth";
+
+const token = "test";
+
+function basic(username: string, password: string): string {
+ return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
+}
+
+describe("loadAuthConfiguration", () => {
+ it("loads the local demo password", () => {
+ expect(loadAuthConfiguration({ SUBMITQUEUE_WEB_TOKEN: token })).toEqual({
+ token,
+ });
+ });
+
+ it.each([undefined, ""])("rejects a missing or empty token", (value) => {
+ expect(() =>
+ loadAuthConfiguration({ SUBMITQUEUE_WEB_TOKEN: value }),
+ ).toThrow(Error);
+ });
+});
+
+describe("isAuthorized", () => {
+ it("accepts the configured credentials", () => {
+ expect(
+ isAuthorized(basic("test", "test"), { token }),
+ ).toBe(true);
+ });
+
+ it.each([
+ ["missing header", null],
+ ["wrong scheme", `Bearer ${token}`],
+ ["malformed base64", "Basic !!!"],
+ ["missing separator", `Basic ${Buffer.from("submitqueue").toString("base64")}`],
+ ["wrong user", basic("operator", token)],
+ ["previous demo user", basic("submitqueue", token)],
+ ["wrong password", basic(BASIC_AUTH_USERNAME, "b".repeat(32))],
+ ])("rejects %s", (_name, authorization) => {
+ expect(isAuthorized(authorization, { token })).toBe(false);
+ });
+
+ it("allows colons in the password", () => {
+ const password = `${token}:suffix`;
+ expect(
+ isAuthorized(basic(BASIC_AUTH_USERNAME, password), { token: password }),
+ ).toBe(true);
+ });
+});
diff --git a/web/service/submitqueue/src/server/auth.ts b/web/service/submitqueue/src/server/auth.ts
new file mode 100644
index 000000000..02bbf68c8
--- /dev/null
+++ b/web/service/submitqueue/src/server/auth.ts
@@ -0,0 +1,83 @@
+import "server-only";
+
+import { createHash, timingSafeEqual } from "node:crypto";
+
+export const BASIC_AUTH_USERNAME = "test";
+export const BASIC_AUTH_REALM = "SubmitQueue demo";
+
+export type AuthConfiguration = Readonly<{
+ token: string;
+}>;
+
+export function loadAuthConfiguration(
+ environment: Readonly> = process.env,
+): AuthConfiguration {
+ const token = environment.SUBMITQUEUE_WEB_TOKEN ?? "";
+ if (token.length === 0) {
+ throw new Error("SUBMITQUEUE_WEB_TOKEN must be set");
+ }
+ return { token };
+}
+
+function decodeBasicCredentials(
+ authorization: string | null,
+): Readonly<{ username: string; password: string }> | undefined {
+ if (authorization === null) {
+ return undefined;
+ }
+
+ const [scheme, encoded, ...extra] = authorization.trim().split(/\s+/u);
+ if (scheme?.toLowerCase() !== "basic" || !encoded || extra.length !== 0) {
+ return undefined;
+ }
+ if (
+ !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded) ||
+ encoded.length % 4 === 1
+ ) {
+ return undefined;
+ }
+
+ const decoded = Buffer.from(encoded, "base64").toString("utf8");
+
+ const separator = decoded.indexOf(":");
+ if (separator < 0) {
+ return undefined;
+ }
+ return {
+ username: decoded.slice(0, separator),
+ password: decoded.slice(separator + 1),
+ };
+}
+
+function digest(value: string): Buffer {
+ return createHash("sha256").update(value, "utf8").digest();
+}
+
+function securelyEqual(actual: string, expected: string): boolean {
+ return timingSafeEqual(digest(actual), digest(expected));
+}
+
+export function isAuthorized(
+ authorization: string | null,
+ configuration: AuthConfiguration,
+): boolean {
+ const credentials = decodeBasicCredentials(authorization);
+ if (!credentials) {
+ return false;
+ }
+ return (
+ securelyEqual(credentials.username, BASIC_AUTH_USERNAME) &&
+ securelyEqual(credentials.password, configuration.token)
+ );
+}
+
+export function authenticationChallenge(): Response {
+ return new Response("Authentication required", {
+ status: 401,
+ headers: {
+ "Cache-Control": "no-store",
+ "Content-Type": "text/plain; charset=utf-8",
+ "WWW-Authenticate": `Basic realm="${BASIC_AUTH_REALM}", charset="UTF-8"`,
+ },
+ });
+}
diff --git a/web/service/submitqueue/src/server/config.test.ts b/web/service/submitqueue/src/server/config.test.ts
new file mode 100644
index 000000000..7f384068c
--- /dev/null
+++ b/web/service/submitqueue/src/server/config.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+
+import { loadGatewayConfiguration } from "./config";
+
+describe("loadGatewayConfiguration", () => {
+ it("uses TLS by default", () => {
+ expect(
+ loadGatewayConfiguration({
+ SUBMITQUEUE_GATEWAY_URL: "https://gateway.example:8081",
+ }),
+ ).toEqual({
+ baseUrl: "https://gateway.example:8081",
+ allowPlaintext: false,
+ });
+ });
+
+ it("allows h2c only when explicitly enabled", () => {
+ expect(
+ loadGatewayConfiguration({
+ SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT: "true",
+ SUBMITQUEUE_GATEWAY_URL: "http://gateway-service:8081",
+ }),
+ ).toEqual({
+ baseUrl: "http://gateway-service:8081",
+ allowPlaintext: true,
+ });
+ });
+
+ it.each([
+ ["missing URL", {}],
+ ["relative URL", { SUBMITQUEUE_GATEWAY_URL: "gateway:8081" }],
+ ["implicit plaintext", { SUBMITQUEUE_GATEWAY_URL: "http://gateway:8081" }],
+ ["unsupported scheme", { SUBMITQUEUE_GATEWAY_URL: "ftp://gateway:8081" }],
+ ["path", { SUBMITQUEUE_GATEWAY_URL: "https://gateway:8081/grpc" }],
+ ["query", { SUBMITQUEUE_GATEWAY_URL: "https://gateway:8081?x=1" }],
+ ["credentials", { SUBMITQUEUE_GATEWAY_URL: "https://user:pass@gateway:8081" }],
+ ])("rejects %s", (_name, environment) => {
+ expect(() => loadGatewayConfiguration(environment)).toThrow();
+ });
+});
diff --git a/web/service/submitqueue/src/server/config.ts b/web/service/submitqueue/src/server/config.ts
new file mode 100644
index 000000000..0d90a6f7d
--- /dev/null
+++ b/web/service/submitqueue/src/server/config.ts
@@ -0,0 +1,49 @@
+import "server-only";
+
+export const DEMO_QUEUE = "demo-queue";
+export const HOST_QUEUES = [
+ { name: DEMO_QUEUE, description: "Local read-only demo backed by the SubmitQueue gateway" },
+];
+export const GATEWAY_DEADLINE_MS = 5_000;
+export const GATEWAY_HEALTH_DEADLINE_MS = 1_000;
+
+export type GatewayConfiguration = Readonly<{
+ baseUrl: string;
+ allowPlaintext: boolean;
+}>;
+
+export function loadGatewayConfiguration(
+ environment: Readonly> = process.env,
+): GatewayConfiguration {
+ const rawBaseUrl = environment.SUBMITQUEUE_GATEWAY_URL;
+ if (!rawBaseUrl) {
+ throw new Error("SUBMITQUEUE_GATEWAY_URL is required");
+ }
+
+ if (!URL.canParse(rawBaseUrl)) {
+ throw new Error("SUBMITQUEUE_GATEWAY_URL must be a valid absolute URL");
+ }
+ const gatewayUrl = new URL(rawBaseUrl);
+
+ if (gatewayUrl.pathname !== "/" || gatewayUrl.search || gatewayUrl.hash) {
+ throw new Error("SUBMITQUEUE_GATEWAY_URL must not contain a path, query, or fragment");
+ }
+ if (gatewayUrl.username || gatewayUrl.password) {
+ throw new Error("SUBMITQUEUE_GATEWAY_URL must not contain credentials");
+ }
+
+ const allowPlaintext = environment.SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT === "true";
+ if (gatewayUrl.protocol === "http:" && !allowPlaintext) {
+ throw new Error(
+ "Plaintext gateway connections require SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT=true",
+ );
+ }
+ if (gatewayUrl.protocol !== "https:" && gatewayUrl.protocol !== "http:") {
+ throw new Error("SUBMITQUEUE_GATEWAY_URL must use https, or explicitly enabled http");
+ }
+
+ return {
+ baseUrl: gatewayUrl.origin,
+ allowPlaintext,
+ };
+}
diff --git a/web/service/submitqueue/src/server/diagnostics.ts b/web/service/submitqueue/src/server/diagnostics.ts
new file mode 100644
index 000000000..13ea5d0f2
--- /dev/null
+++ b/web/service/submitqueue/src/server/diagnostics.ts
@@ -0,0 +1,14 @@
+import "server-only";
+
+import type {
+ GatewayDiagnostic,
+ GatewayDiagnostics,
+} from "@submitqueue/web-submitqueue/server";
+
+function reportGatewayError(diagnostic: GatewayDiagnostic): void {
+ console.error("SubmitQueue gateway request failed", diagnostic);
+}
+
+export const gatewayDiagnostics: GatewayDiagnostics = {
+ onGatewayError: reportGatewayError,
+};
diff --git a/web/service/submitqueue/src/server/gateway.ts b/web/service/submitqueue/src/server/gateway.ts
new file mode 100644
index 000000000..4fe3a25ae
--- /dev/null
+++ b/web/service/submitqueue/src/server/gateway.ts
@@ -0,0 +1,44 @@
+import "server-only";
+
+import { createClient, type Client } from "@connectrpc/connect";
+import { createGrpcTransport } from "@connectrpc/connect-node";
+import { SubmitQueueGateway } from "@submitqueue/api/submitqueue/gateway";
+import type {
+ GatewayReader,
+ GatewayResolver,
+} from "@submitqueue/web-submitqueue/server";
+import { gatewayReader } from "@submitqueue/web-submitqueue/server";
+
+import {
+ GATEWAY_DEADLINE_MS,
+ GATEWAY_HEALTH_DEADLINE_MS,
+ loadGatewayConfiguration,
+} from "./config";
+
+let client: Client | undefined;
+let reader: GatewayReader | undefined;
+
+export function resolveGatewayClient(): Client {
+ if (client) {
+ return client;
+ }
+ const configuration = loadGatewayConfiguration();
+ const transport = createGrpcTransport({
+ baseUrl: configuration.baseUrl,
+ defaultTimeoutMs: GATEWAY_DEADLINE_MS,
+ });
+ client = createClient(SubmitQueueGateway, transport);
+ return client;
+}
+
+export const resolveDemoGateway: GatewayResolver = () => {
+ reader ??= gatewayReader(resolveGatewayClient());
+ return reader;
+};
+
+export async function pingDemoGateway(): Promise {
+ await resolveGatewayClient().ping(
+ { message: "submitqueue-web-readiness" },
+ { timeoutMs: GATEWAY_HEALTH_DEADLINE_MS },
+ );
+}
diff --git a/web/service/submitqueue/src/server/request-auth.ts b/web/service/submitqueue/src/server/request-auth.ts
new file mode 100644
index 000000000..86dd45a29
--- /dev/null
+++ b/web/service/submitqueue/src/server/request-auth.ts
@@ -0,0 +1,18 @@
+import "server-only";
+
+import { headers } from "next/headers";
+import { unauthorized } from "next/navigation";
+
+import { isAuthorized, loadAuthConfiguration } from "./auth";
+
+export async function requireAuthorization(): Promise {
+ const requestHeaders = await headers();
+ if (
+ !isAuthorized(
+ requestHeaders.get("authorization"),
+ loadAuthConfiguration(),
+ )
+ ) {
+ unauthorized();
+ }
+}
diff --git a/web/service/submitqueue/src/test/server-only.ts b/web/service/submitqueue/src/test/server-only.ts
new file mode 100644
index 000000000..cb0ff5c3b
--- /dev/null
+++ b/web/service/submitqueue/src/test/server-only.ts
@@ -0,0 +1 @@
+export {};
diff --git a/web/service/submitqueue/tsconfig.json b/web/service/submitqueue/tsconfig.json
new file mode 100644
index 000000000..2523b07a6
--- /dev/null
+++ b/web/service/submitqueue/tsconfig.json
@@ -0,0 +1,36 @@
+{
+ "compilerOptions": {
+ "allowJs": false,
+ "esModuleInterop": true,
+ "incremental": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2022"
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/web/service/submitqueue/vitest.config.ts b/web/service/submitqueue/vitest.config.ts
new file mode 100644
index 000000000..35ed019bb
--- /dev/null
+++ b/web/service/submitqueue/vitest.config.ts
@@ -0,0 +1,18 @@
+import path from "node:path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ "server-only": path.resolve(import.meta.dirname, "src/test/server-only.ts"),
+ },
+ },
+ test: {
+ environment: "node",
+ server: {
+ deps: {
+ inline: ["@submitqueue/web-submitqueue"],
+ },
+ },
+ },
+});