From 265adbf96688dce571968e0681f552005a307a79 Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Mon, 5 Oct 2026 16:05:39 -0700 Subject: [PATCH 1/2] feat(web): add authenticated Next.js demo host ## Summary ### Why? The reusable library needs a reference host that owns framework behavior, authentication, gateway connectivity, and deployment. ### What? Add the Next.js shell, Basic authentication, server-side h2c/TLS transport validation, readiness checks, host-supplied refresh transitions, styling, and Bazel standalone/OCI targets. Business pages are introduced in subsequent changes. ## Test Plan Passed nine API/library/host check targets and the production Next standalone build. Passed repository formatting, lint, module tidy, and Gazelle checks. --- web/BUILD.bazel | 3 + web/service/submitqueue/BUILD.bazel | 182 +++++++++++++++++- web/service/submitqueue/README.md | 7 + web/service/submitqueue/eslint.config.mjs | 7 + web/service/submitqueue/next-env.d.ts | 7 + web/service/submitqueue/next.config.ts | 16 ++ web/service/submitqueue/package.json | 9 +- web/service/submitqueue/public/robots.txt | 2 + .../src/app/(protected)/layout.tsx | 31 +++ .../submitqueue/src/app/(protected)/page.tsx | 11 ++ web/service/submitqueue/src/app/globals.css | 110 +++++++++++ .../submitqueue/src/app/healthz/route.test.ts | 80 ++++++++ .../submitqueue/src/app/healthz/route.ts | 50 +++++ web/service/submitqueue/src/app/layout.tsx | 20 ++ web/service/submitqueue/src/app/not-found.tsx | 16 ++ .../submitqueue/src/app/unauthorized.tsx | 11 ++ .../src/components/next-refresh.tsx | 37 ++++ web/service/submitqueue/src/proxy.test.ts | 46 +++++ web/service/submitqueue/src/proxy.ts | 21 ++ .../submitqueue/src/server/auth.test.ts | 54 ++++++ web/service/submitqueue/src/server/auth.ts | 86 +++++++++ .../submitqueue/src/server/config.test.ts | 40 ++++ web/service/submitqueue/src/server/config.ts | 49 +++++ .../submitqueue/src/server/diagnostics.ts | 14 ++ web/service/submitqueue/src/server/gateway.ts | 44 +++++ .../submitqueue/src/server/request-auth.ts | 18 ++ .../submitqueue/src/test/server-only.ts | 1 + web/service/submitqueue/tsconfig.json | 36 ++++ web/service/submitqueue/vitest.config.ts | 18 ++ 29 files changed, 1024 insertions(+), 2 deletions(-) create mode 100644 web/service/submitqueue/README.md create mode 100644 web/service/submitqueue/eslint.config.mjs create mode 100644 web/service/submitqueue/next-env.d.ts create mode 100644 web/service/submitqueue/next.config.ts create mode 100644 web/service/submitqueue/public/robots.txt create mode 100644 web/service/submitqueue/src/app/(protected)/layout.tsx create mode 100644 web/service/submitqueue/src/app/(protected)/page.tsx create mode 100644 web/service/submitqueue/src/app/globals.css create mode 100644 web/service/submitqueue/src/app/healthz/route.test.ts create mode 100644 web/service/submitqueue/src/app/healthz/route.ts create mode 100644 web/service/submitqueue/src/app/layout.tsx create mode 100644 web/service/submitqueue/src/app/not-found.tsx create mode 100644 web/service/submitqueue/src/app/unauthorized.tsx create mode 100644 web/service/submitqueue/src/components/next-refresh.tsx create mode 100644 web/service/submitqueue/src/proxy.test.ts create mode 100644 web/service/submitqueue/src/proxy.ts create mode 100644 web/service/submitqueue/src/server/auth.test.ts create mode 100644 web/service/submitqueue/src/server/auth.ts create mode 100644 web/service/submitqueue/src/server/config.test.ts create mode 100644 web/service/submitqueue/src/server/config.ts create mode 100644 web/service/submitqueue/src/server/diagnostics.ts create mode 100644 web/service/submitqueue/src/server/gateway.ts create mode 100644 web/service/submitqueue/src/server/request-auth.ts create mode 100644 web/service/submitqueue/src/test/server-only.ts create mode 100644 web/service/submitqueue/tsconfig.json create mode 100644 web/service/submitqueue/vitest.config.ts 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..664b558b3 --- /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 at least 32 characters for username `submitqueue`, configure `SUBMITQUEUE_GATEWAY_URL`, and explicitly enable `SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT=true` only for local h2c. + +`/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 ( +
+
+ + + SubmitQueue + + Local demo · read-only +
+ {children} +
+ Read-only demo · updates automatically +
+
+ ); +} 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..9aca2665d --- /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([ + ["a short token", "short", "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", "short"); + await GET(); + await GET(); + + expect(error).toHaveBeenCalledOnce(); + expect(error).toHaveBeenCalledWith( + "SubmitQueue web configuration is invalid", + { message: "SUBMITQUEUE_WEB_TOKEN must contain at least 32 characters" }, + ); + }); + + 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..e7b66da21 --- /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, + MINIMUM_TOKEN_LENGTH, +} from "./auth"; + +const token = "a".repeat(MINIMUM_TOKEN_LENGTH); + +function basic(username: string, password: string): string { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +} + +describe("loadAuthConfiguration", () => { + it("loads a sufficiently long token", () => { + expect(loadAuthConfiguration({ SUBMITQUEUE_WEB_TOKEN: token })).toEqual({ + token, + }); + }); + + it.each([undefined, "short"])("rejects a missing or short token", (value) => { + expect(() => + loadAuthConfiguration({ SUBMITQUEUE_WEB_TOKEN: value }), + ).toThrow(/at least 32 characters/u); + }); +}); + +describe("isAuthorized", () => { + it("accepts the configured credentials", () => { + expect( + isAuthorized(basic(BASIC_AUTH_USERNAME, token), { 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)], + ["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..eccf88ba6 --- /dev/null +++ b/web/service/submitqueue/src/server/auth.ts @@ -0,0 +1,86 @@ +import "server-only"; + +import { createHash, timingSafeEqual } from "node:crypto"; + +export const BASIC_AUTH_USERNAME = "submitqueue"; +export const BASIC_AUTH_REALM = "SubmitQueue demo"; +export const MINIMUM_TOKEN_LENGTH = 32; + +export type AuthConfiguration = Readonly<{ + token: string; +}>; + +export function loadAuthConfiguration( + environment: Readonly> = process.env, +): AuthConfiguration { + const token = environment.SUBMITQUEUE_WEB_TOKEN ?? ""; + if (token.length < MINIMUM_TOKEN_LENGTH) { + throw new Error( + `SUBMITQUEUE_WEB_TOKEN must contain at least ${MINIMUM_TOKEN_LENGTH} characters`, + ); + } + 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"], + }, + }, + }, +}); From f706c9507999b1da678bcc4442a9281e74b72f23 Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Tue, 6 Oct 2026 11:50:13 -0700 Subject: [PATCH 2/2] fix(web): accept test credentials in the local demo ## Summary ### Why? Local testing needs the requested test/test login rather than a long demo password. ### What? Use test as the Basic-auth username and permit nonempty demo passwords while still rejecting missing configuration and invalid credentials. ## Test Plan Passed web checks and all four real-stack browser tests with test/test credentials. --- web/service/submitqueue/README.md | 2 +- .../submitqueue/src/app/healthz/route.test.ts | 6 +++--- web/service/submitqueue/src/server/auth.test.ts | 12 ++++++------ web/service/submitqueue/src/server/auth.ts | 9 +++------ 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/web/service/submitqueue/README.md b/web/service/submitqueue/README.md index 664b558b3..f70d5c3d9 100644 --- a/web/service/submitqueue/README.md +++ b/web/service/submitqueue/README.md @@ -2,6 +2,6 @@ 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 at least 32 characters for username `submitqueue`, configure `SUBMITQUEUE_GATEWAY_URL`, and explicitly enable `SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT=true` only for local h2c. +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/src/app/healthz/route.test.ts b/web/service/submitqueue/src/app/healthz/route.test.ts index 9aca2665d..57555012c 100644 --- a/web/service/submitqueue/src/app/healthz/route.test.ts +++ b/web/service/submitqueue/src/app/healthz/route.test.ts @@ -30,7 +30,7 @@ describe("GET /healthz", () => { }); it.each([ - ["a short token", "short", "https://gateway.example:8080"], + ["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); @@ -48,14 +48,14 @@ describe("GET /healthz", () => { await GET(); const error = vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", "short"); + 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 contain at least 32 characters" }, + { message: "SUBMITQUEUE_WEB_TOKEN must be set" }, ); }); diff --git a/web/service/submitqueue/src/server/auth.test.ts b/web/service/submitqueue/src/server/auth.test.ts index e7b66da21..c7bfd20a9 100644 --- a/web/service/submitqueue/src/server/auth.test.ts +++ b/web/service/submitqueue/src/server/auth.test.ts @@ -4,33 +4,32 @@ import { BASIC_AUTH_USERNAME, isAuthorized, loadAuthConfiguration, - MINIMUM_TOKEN_LENGTH, } from "./auth"; -const token = "a".repeat(MINIMUM_TOKEN_LENGTH); +const token = "test"; function basic(username: string, password: string): string { return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; } describe("loadAuthConfiguration", () => { - it("loads a sufficiently long token", () => { + it("loads the local demo password", () => { expect(loadAuthConfiguration({ SUBMITQUEUE_WEB_TOKEN: token })).toEqual({ token, }); }); - it.each([undefined, "short"])("rejects a missing or short token", (value) => { + it.each([undefined, ""])("rejects a missing or empty token", (value) => { expect(() => loadAuthConfiguration({ SUBMITQUEUE_WEB_TOKEN: value }), - ).toThrow(/at least 32 characters/u); + ).toThrow(Error); }); }); describe("isAuthorized", () => { it("accepts the configured credentials", () => { expect( - isAuthorized(basic(BASIC_AUTH_USERNAME, token), { token }), + isAuthorized(basic("test", "test"), { token }), ).toBe(true); }); @@ -40,6 +39,7 @@ describe("isAuthorized", () => { ["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); diff --git a/web/service/submitqueue/src/server/auth.ts b/web/service/submitqueue/src/server/auth.ts index eccf88ba6..02bbf68c8 100644 --- a/web/service/submitqueue/src/server/auth.ts +++ b/web/service/submitqueue/src/server/auth.ts @@ -2,9 +2,8 @@ import "server-only"; import { createHash, timingSafeEqual } from "node:crypto"; -export const BASIC_AUTH_USERNAME = "submitqueue"; +export const BASIC_AUTH_USERNAME = "test"; export const BASIC_AUTH_REALM = "SubmitQueue demo"; -export const MINIMUM_TOKEN_LENGTH = 32; export type AuthConfiguration = Readonly<{ token: string; @@ -14,10 +13,8 @@ export function loadAuthConfiguration( environment: Readonly> = process.env, ): AuthConfiguration { const token = environment.SUBMITQUEUE_WEB_TOKEN ?? ""; - if (token.length < MINIMUM_TOKEN_LENGTH) { - throw new Error( - `SUBMITQUEUE_WEB_TOKEN must contain at least ${MINIMUM_TOKEN_LENGTH} characters`, - ); + if (token.length === 0) { + throw new Error("SUBMITQUEUE_WEB_TOKEN must be set"); } return { token }; }