From 04f91070d45ee3db9f0e1a8dee6838cb785bcb9c Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Mon, 5 Oct 2026 16:07:56 -0700 Subject: [PATCH 1/2] feat(web): add queue directory and live request list ## Summary ### Why? Users need a queue entry point and a live request list that can refresh without timestamp-pinned URLs. ### What? Add queue-directory and searchable request-table library components, the demo queue routes, stale-snapshot presentation, and signed snapshot pagination. Keep resource IDs opaque and refresh the default trailing receipt window on each load. ## Test Plan Passed nine API/library/host check targets and the production Next standalone build, including queue/window component and cursor tests. Passed repository formatting, lint, module tidy, and Gazelle checks. --- web/package_test/consumer.ts | 4 +- .../src/app/(protected)/[queue]/page.tsx | 107 ++++++++++++++++++ .../submitqueue/src/app/(protected)/page.tsx | 5 +- .../submitqueue/src/server/window.test.ts | 34 ++++++ web/service/submitqueue/src/server/window.ts | 57 ++++++++++ web/submitqueue/src/components.test.tsx | 80 ++++++++++++- web/submitqueue/src/components.tsx | 79 +++++++++++++ web/submitqueue/src/index.ts | 4 + 8 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 web/service/submitqueue/src/app/(protected)/[queue]/page.tsx create mode 100644 web/service/submitqueue/src/server/window.test.ts create mode 100644 web/service/submitqueue/src/server/window.ts diff --git a/web/package_test/consumer.ts b/web/package_test/consumer.ts index 5e3a6491..4d4dc2d4 100644 --- a/web/package_test/consumer.ts +++ b/web/package_test/consumer.ts @@ -1,8 +1,10 @@ -import { AutoRefresh, RequestStatus } from "@submitqueue/web-submitqueue"; +import { AutoRefresh, RequestStatus, RequestList, QueueDirectory } from "@submitqueue/web-submitqueue"; import { loadRequestList } from "@submitqueue/web-submitqueue/server"; import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/testing"; void AutoRefresh; void RequestStatus; +void RequestList; +void QueueDirectory; void loadRequestList; void createFakeGatewayReader; diff --git a/web/service/submitqueue/src/app/(protected)/[queue]/page.tsx b/web/service/submitqueue/src/app/(protected)/[queue]/page.tsx new file mode 100644 index 00000000..7b4faa9b --- /dev/null +++ b/web/service/submitqueue/src/app/(protected)/[queue]/page.tsx @@ -0,0 +1,107 @@ +import { + RequestListView, + Timestamp, +} from "@submitqueue/web-submitqueue"; +import { + loadRequestList, + WebPaths, + decodePathSegment, +} from "@submitqueue/web-submitqueue/server"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { DEMO_QUEUE } from "../../../server/config"; +import { gatewayDiagnostics } from "../../../server/diagnostics"; +import { resolveDemoGateway } from "../../../server/gateway"; +import { requireAuthorization } from "../../../server/request-auth"; +import { NextRefresh } from "../../../components/next-refresh"; +import { loadAuthConfiguration } from "../../../server/auth"; +import { + defaultRequestWindow, + decodeRequestPage, + encodeRequestPage, + REQUEST_PAGE_SIZE, + type RequestSearchParams, +} from "../../../server/window"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export const metadata: Metadata = { + title: "Requests", +}; + +const paths = new WebPaths(); + +export default async function QueueRequestsPage({ + params, + searchParams, +}: Readonly<{ + params: Promise<{ queue: string }>; + searchParams: Promise; +}>) { + await connection(); + await requireAuthorization(); + + const queue = decodePathSegment((await params).queue); + if (queue !== DEMO_QUEUE) { + notFound(); + } + + const search = await searchParams; + if (search.from !== undefined || search.to !== undefined || + (search.page !== undefined && typeof search.page !== "string")) { + redirect(paths.requests(queue)); + } + const secret = loadAuthConfiguration().token; + const pageWindow = typeof search.page === "string" ? decodeRequestPage(queue, search.page, secret) : undefined; + if (search.page !== undefined && pageWindow === undefined) { + redirect(paths.requests(queue)); + } + const requestWindow = pageWindow ?? defaultRequestWindow(); + + const loaded = await loadRequestList( + resolveDemoGateway, + { + queue, + receivedAtOrAfterMs: requestWindow.fromMs, + receivedBeforeMs: requestWindow.toMs, + pageSize: REQUEST_PAGE_SIZE, + pageToken: requestWindow.pageToken, + }, + { diagnostics: gatewayDiagnostics }, + ); + const result = loaded.ok && loaded.data.nextPageToken ? { + ...loaded, + data: { ...loaded.data, nextPageToken: encodeRequestPage(queue, requestWindow, loaded.data.nextPageToken, secret) }, + } : loaded; + + return ( +
+
+
+

Queue activity

+

{queue}

+

{pageWindow ? "Older requests · snapshot" : "Last 24 hours · updates on refresh"}: —

+
+
+ Latest 24 hours + +
+
+ +
+ +
+
+ ); +} diff --git a/web/service/submitqueue/src/app/(protected)/page.tsx b/web/service/submitqueue/src/app/(protected)/page.tsx index 49cf35e5..4f220831 100644 --- a/web/service/submitqueue/src/app/(protected)/page.tsx +++ b/web/service/submitqueue/src/app/(protected)/page.tsx @@ -1,4 +1,7 @@ import { connection } from "next/server"; +import { QueueDirectory } from "@submitqueue/web-submitqueue"; + +import { HOST_QUEUES } from "../../server/config"; import { requireAuthorization } from "../../server/request-auth"; export const dynamic = "force-dynamic"; @@ -7,5 +10,5 @@ export const revalidate = 0; export default async function HomePage() { await connection(); await requireAuthorization(); - return

SubmitQueue

Read-only demo host

; + return
; } diff --git a/web/service/submitqueue/src/server/window.test.ts b/web/service/submitqueue/src/server/window.test.ts new file mode 100644 index 00000000..59403246 --- /dev/null +++ b/web/service/submitqueue/src/server/window.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { defaultRequestWindow, decodeRequestPage, encodeRequestPage, REQUEST_WINDOW_MS } from "./window"; + +const secret = "test-pagination-secret"; +const queue = "demo-queue"; +const window = defaultRequestWindow(REQUEST_WINDOW_MS + 100); + +describe("live request window and snapshot pagination", () => { + it("advances the default window each time it is refreshed", () => { + expect(defaultRequestWindow(REQUEST_WINDOW_MS + 100)).toEqual({ fromMs: 100, toMs: REQUEST_WINDOW_MS + 100 }); + expect(defaultRequestWindow(REQUEST_WINDOW_MS + 200)).toEqual({ fromMs: 200, toMs: REQUEST_WINDOW_MS + 200 }); + }); + + it("round-trips stable gateway paging bounds inside one opaque cursor", () => { + const cursor = encodeRequestPage(queue, window, "opaque/token+=\nvalue", secret); + expect(decodeRequestPage(queue, cursor, secret)).toEqual({ ...window, pageToken: "opaque/token+=\nvalue" }); + }); + + it("rejects altered, cross-queue, or differently signed cursors", () => { + const cursor = encodeRequestPage(queue, window, "next", secret); + expect(decodeRequestPage("other-queue", cursor, secret)).toBeUndefined(); + expect(decodeRequestPage(queue, `${cursor}x`, secret)).toBeUndefined(); + expect(decodeRequestPage(queue, cursor, "other-secret")).toBeUndefined(); + }); + + it.each(["", "not-a-cursor", ".invalid", "a".repeat(16_385)])("rejects malformed cursor", (cursor) => { + expect(decodeRequestPage(queue, cursor, secret)).toBeUndefined(); + }); + + it("rejects signed cursors with missing tokens or invalid snapshot bounds", () => { + expect(decodeRequestPage(queue, encodeRequestPage(queue, window, "", secret), secret)).toBeUndefined(); + expect(decodeRequestPage(queue, encodeRequestPage(queue, { fromMs: 200, toMs: 100 }, "next", secret), secret)).toBeUndefined(); + }); +}); diff --git a/web/service/submitqueue/src/server/window.ts b/web/service/submitqueue/src/server/window.ts new file mode 100644 index 00000000..2c8416d7 --- /dev/null +++ b/web/service/submitqueue/src/server/window.ts @@ -0,0 +1,57 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +export const REQUEST_WINDOW_MS = 24 * 60 * 60 * 1_000; +export const REQUEST_PAGE_SIZE = 50; + +export type RequestWindow = Readonly<{ + fromMs: number; + toMs: number; + pageToken?: string; +}>; + +export type RequestSearchParams = Readonly<{ + page?: string | string[]; + from?: string | string[]; + to?: string | string[]; +}>; + +export function defaultRequestWindow(nowMs = Date.now()): RequestWindow { + return { fromMs: nowMs - REQUEST_WINDOW_MS, toMs: nowMs }; +} + +function parseMillisecond(value: string | undefined): number | undefined { + if (!value || !/^\d+$/u.test(value)) { + return undefined; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +export function encodeRequestPage(queue: string, window: RequestWindow, token: string, secret: string): string { + const body = Buffer.from( + ["v1", encodeURIComponent(queue), window.fromMs, window.toMs, token].join("\n"), + ).toString("base64url"); + const signature = createHmac("sha256", secret).update(body).digest("base64url"); + return `${body}.${signature}`; +} + +export function decodeRequestPage(queue: string, cursor: string, secret: string): RequestWindow | undefined { + if (cursor.length > 16_384 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u.test(cursor)) { + return undefined; + } + const [body, signature] = cursor.split("."); + const actual = Buffer.from(signature!, "base64url"); + const expected = createHmac("sha256", secret).update(body!).digest(); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + return undefined; + } + const [version, scope, from, to, ...tokenParts] = Buffer.from(body!, "base64url").toString("utf8").split("\n"); + const fromMs = parseMillisecond(from); + const toMs = parseMillisecond(to); + const pageToken = tokenParts.join("\n"); + if (version !== "v1" || scope !== encodeURIComponent(queue) || fromMs === undefined || + toMs === undefined || toMs - fromMs !== REQUEST_WINDOW_MS || !pageToken) { + return undefined; + } + return { fromMs, toMs, pageToken }; +} diff --git a/web/submitqueue/src/components.test.tsx b/web/submitqueue/src/components.test.tsx index 95c95192..8930d94a 100644 --- a/web/submitqueue/src/components.test.tsx +++ b/web/submitqueue/src/components.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { ErrorState, RequestStatus } from "./components"; +import { ErrorState, RequestStatus, QueueDirectory, RequestList, RequestListView } from "./components"; import type { RequestDetailModel, RequestListModel } from "./models"; const request = { @@ -15,6 +15,84 @@ const request = { describe("request components", () => { + it("renders a linked queue request with its complete sqid", () => { + const model: RequestListModel = { + queue: "demo-queue", + receivedAtOrAfterMs: 1, + receivedBeforeMs: 2, + requests: [request], + nextPageToken: "opaque-token", + }; + render(); + + const link = screen.getByRole("link", { name: "demo-queue/1" }); + expect(link.getAttribute("href")).toBe( + "/demo-queue/request/demo-queue/1", + ); + expect(screen.getByText("Speculating").getAttribute("data-tone")).toBe("progress"); + expect(screen.getByText("Nov 14, 2023, 10:13:20 PM UTC")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Next page" }).getAttribute("href")).toBe( + "/demo-queue?page=opaque-token", + ); + }); + + it("renders an accessible empty state", () => { + render( + , + ); + expect(screen.getByText("No requests were received in this window.")).toBeTruthy(); + }); + + it("lists configured queues even when there is only one", () => { + render(); + expect(screen.getByRole("link", { name: "demo-queue" }).getAttribute("href")).toBe("/demo-queue"); + }); + + it("filters only the displayed page without discarding its pagination link", () => { + render(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "landed" } }); + expect(screen.queryByRole("link", { name: request.sqid })).toBeNull(); + expect(screen.getByRole("link", { name: "42" })).toBeTruthy(); + expect(screen.getByRole("link", { name: "Next page" })).toBeTruthy(); + }); + + it("uses host-supplied change labels and links without exposing fake file hints", () => { + const raw = "git://git.example.com/demo/refs%2Fheads%2Fmain/sha?sq-files=demo%2Ffile.txt"; + const clean = raw.split("?")[0]!; + render(); + expect(screen.getByRole("link", { name: clean }).getAttribute("href")).toBe("/change"); + expect(screen.queryByText(/sq-files=/)).toBeNull(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "file.txt" } }); + expect(screen.getByText("No displayed requests match.")).toBeTruthy(); + }); + + it("retains the last successful list on a transient failure, but not a permanent failure", () => { + const view = render(); + const error = { kind: "transient" as const, title: "Unavailable", message: "Retry", retryable: true }; + view.rerender(); + expect(screen.getByRole("link", { name: request.sqid })).toBeTruthy(); + expect(screen.getByText(/last successful snapshot/)).toBeTruthy(); + view.rerender(); + expect(screen.queryByRole("link", { name: request.sqid })).toBeNull(); + }); it("shows unknown statuses safely", () => { render(); diff --git a/web/submitqueue/src/components.tsx b/web/submitqueue/src/components.tsx index c94faa8a..a099b059 100644 --- a/web/submitqueue/src/components.tsx +++ b/web/submitqueue/src/components.tsx @@ -29,8 +29,87 @@ export function RequestStatus({ status }: { status: string }) { return {display.label}; } +function RequestChanges({ request, links = {}, labels = {} }: { + request: RequestSummaryModel; links?: Record; labels?: Record; +}) { + return ; +} + +export function QueueDirectory({ queues, basePath }: { queues: readonly QueueModel[]; basePath?: string }) { + const paths = new WebPaths(basePath === undefined ? {} : { basePath }); + return
+

Queues

Queues configured by this host

+ {queues.length === 0 ?

No queues are configured.

: + } +
; +} + +export interface RequestListProps { + model: RequestListModel; + basePath?: string; + empty?: ReactNode; + changeLinks?: Record; + changeLabels?: Record; +} + +export function RequestList({ model, basePath, empty, changeLinks, changeLabels }: RequestListProps) { + const [search, setSearch] = useState(""); + const paths = new WebPaths(basePath === undefined ? {} : { basePath }); + const requests = model.requests.filter(request => + [request.sqid, request.status, ...request.changeUris.map(uri => changeLabels?.[uri] ?? uri)] + .some(value => value.toLowerCase().includes(search.toLowerCase())) + ); + return
+
+ + Displayed page · UTC +
+ {model.requests.length === 0 ?
{empty ?? "No requests were received in this window."}
: + requests.length === 0 ?

No displayed requests match.

: +
+ + {requests.map(request => + + + + + )} +
Request IDStatusChangesReceived · UTC
{request.sqid}{request.lastError ?

{request.lastError}

: null}
} +
{requests.length} displayed requests · newest received first + {model.nextPageToken ? : null} +
+
; +} + export function ErrorState({ error, compact = false }: { error: WebError; compact?: boolean }) { return

{error.title}

{error.message}

; } + +function useLastSuccessfulData(result: LoadResult): T | undefined { + const [snapshot, setSnapshot] = useState<{ result: LoadResult; data: T | undefined }>(() => ({ + result, data: result.ok ? result.data : undefined, + })); + if (snapshot.result !== result) { + setSnapshot({ + result, + data: result.ok ? result.data : result.error.retryable ? snapshot.data : undefined, + }); + } + return result.ok ? result.data : result.error.retryable ? snapshot.data : undefined; +} + +export function RequestListView({ result, ...props }: Omit & { + result: LoadResult; +}) { + const data = useLastSuccessfulData(result); + return <> + {!result.ok ? <>{data ?

Showing the last successful snapshot; updates are unavailable.

: null} : null} + {data ? : null} + ; +} diff --git a/web/submitqueue/src/index.ts b/web/submitqueue/src/index.ts index 7791df53..8556d766 100644 --- a/web/submitqueue/src/index.ts +++ b/web/submitqueue/src/index.ts @@ -5,6 +5,10 @@ export { ErrorState, Timestamp, RequestStatus, + QueueDirectory, + RequestList, + RequestListView, + type RequestListProps, } from "./components.js"; export type { QueueModel, From e377bfa85fcbd59b1e27d6d4937c07e0236daddd Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Tue, 6 Oct 2026 11:50:14 -0700 Subject: [PATCH 2/2] fix(web): decode queue names at the Next.js boundary The pinned Next.js runtime supplies encoded route parameters, which must not be sent as queue identities to the gateway. URL-decode the queue segment once before removing the resource-path escape and validating the configured queue. Passed web checks and the real-stack browser suite. --- web/service/submitqueue/src/app/(protected)/[queue]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/service/submitqueue/src/app/(protected)/[queue]/page.tsx b/web/service/submitqueue/src/app/(protected)/[queue]/page.tsx index 7b4faa9b..a751fa25 100644 --- a/web/service/submitqueue/src/app/(protected)/[queue]/page.tsx +++ b/web/service/submitqueue/src/app/(protected)/[queue]/page.tsx @@ -45,7 +45,7 @@ export default async function QueueRequestsPage({ await connection(); await requireAuthorization(); - const queue = decodePathSegment((await params).queue); + const queue = decodePathSegment(decodeURIComponent((await params).queue)); if (queue !== DEMO_QUEUE) { notFound(); }