From 5757da9a1d195c667499044a5b3a49811e7613b6 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:33:52 +0800 Subject: [PATCH 01/14] test(console): define hosted page grants and adapter routes --- apps/console/package.json | 8 +++ apps/console/test/console.test.ts | 116 ++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 apps/console/package.json create mode 100644 apps/console/test/console.test.ts diff --git a/apps/console/package.json b/apps/console/package.json new file mode 100644 index 000000000..ac1f158a9 --- /dev/null +++ b/apps/console/package.json @@ -0,0 +1,8 @@ +{ + "name": "@contextful/console", + "private": true, + "type": "module", + "scripts": { + "test": "node --experimental-strip-types --test test/*.test.ts" + } +} diff --git a/apps/console/test/console.test.ts b/apps/console/test/console.test.ts new file mode 100644 index 000000000..d60e56640 --- /dev/null +++ b/apps/console/test/console.test.ts @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { generateKeyPairSync, sign } from "node:crypto"; +import { createConsole, issueCognitoSession } from "../src/index.ts"; + +const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const issuer = "https://access.example"; +const queryAudience = "query-app"; +const adminAudience = "admin-app"; + +function jwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"); + const claims = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${claims}.${sign("RSA-SHA256", Buffer.from(`${header}.${claims}`), privateKey).toString("base64url")}`; +} + +function access(aud: string, extra: Record = {}): HeadersInit { + return { "cf-access-jwt-assertion": jwt({ iss: issuer, aud, sub: "operator-1", exp: Math.floor(Date.now() / 1000) + 300, ...extra }) }; +} + +function fixture(adminCapability = "server-secret") { + const calls: string[] = []; + const console = createConsole({ + identity: { kind: "access", issuer, queryAudience, adminAudience, publicKey }, + adminCapability, + stores: [{ id: "field-notes", label: "Field notes" }], + turn: async ({ question, operator, store }) => { + calls.push(`turn:${operator.subject}:${store}:${question}`); + return { answer: "Three filings arrived.", sources: [{ title: "Filings", url: "https://example.test/filings" }], widgets: [{ component: "table.v1", props: { columns: ["Name"], rows: [["One"]] } }] }; + }, + read: { list: async () => [{ id: "field-notes", label: "Field notes" }] }, + control: { + workflows: async () => { calls.push("workflows"); return { pipelines: [{ id: "filings", steps: ["fetch"], schedule: "every 1h", runs: [] }], annotations: [] }; }, + record: async () => ({ packs: [], learnings: [] }), + edit: async (_body, capability) => { calls.push(`edit:${capability}`); return { version: 2 }; }, + apply: async (_body, capability) => { calls.push(`apply:${capability}`); return { version: 3 }; }, + }, + }); + const request = (path: string, headers?: HeadersInit, init: RequestInit = {}) => console.fetch(new Request(`https://console.example${path}`, { ...init, headers })); + return { request, calls }; +} + +test("anonymous and forged Access assertions dispatch no page or API", async () => { + const { request, calls } = fixture(); + for (const path of ["/query", "/query/api/stores", "/admin", "/admin/api/workflows"]) { + assert.equal((await request(path)).status, 403); + assert.equal((await request(path, { "cf-access-jwt-assertion": "forged" })).status, 403); + } + assert.deepEqual(calls, []); +}); + +test("verified Access audiences grant only their page and API", async () => { + const { request, calls } = fixture(); + assert.equal((await request("/query", access(queryAudience))).status, 200); + assert.equal((await request("/query/api/stores", access(queryAudience))).status, 200); + assert.equal((await request("/admin", access(queryAudience))).status, 403); + assert.equal((await request("/admin/api/workflows", access(queryAudience))).status, 403); + assert.equal((await request("/query", access(adminAudience))).status, 403); + assert.equal((await request("/admin", access(adminAudience))).status, 200); + assert.equal((await request("/admin/api/workflows", access(adminAudience))).status, 200); + assert.deepEqual(calls, ["workflows"]); +}); + +test("expired or wrong-issuer assertions cannot open a page", async () => { + const { request } = fixture(); + assert.equal((await request("/query", access(queryAudience, { exp: 1 }))).status, 403); + assert.equal((await request("/query", access(queryAudience, { iss: "https://other.example" }))).status, 403); +}); + +test("Query keeps one composer and transcript and delegates a sourced answer", async () => { + const { request, calls } = fixture(); + const page = await (await request("/query", access(queryAudience))).text(); + assert.match(page, /id="composer"/); + assert.match(page, /id="transcript"/); + assert.match(page, /id="widgets"/); + const answer = await request("/query/api/ask", access(queryAudience), { method: "POST", body: JSON.stringify({ store: "field-notes", question: "What arrived?" }) }); + assert.equal(answer.status, 200); + assert.equal((await answer.json() as { sources: unknown[] }).sources.length, 1); + assert.deepEqual(calls, ["turn:operator-1:field-notes:What arrived?"]); +}); + +test("Admin exposes workflows and requires a server-held capability for edit and apply", async () => { + const { request, calls } = fixture(); + const workflows = await request("/admin/api/workflows", access(adminAudience)); + assert.equal((await workflows.json() as { pipelines: unknown[] }).pipelines.length, 1); + const edit = await request("/admin/api/edit", access(adminAudience), { method: "POST", body: "{}" }); + assert.equal(edit.status, 200); + const apply = await request("/admin/api/apply", access(adminAudience), { method: "POST", body: "{}" }); + assert.equal(apply.status, 200); + assert.deepEqual(calls, ["workflows", "edit:server-secret", "apply:server-secret"]); + const withoutCapability = fixture(""); + const refused = await withoutCapability.request("/admin/api/apply", access(adminAudience), { method: "POST", body: "{}" }); + assert.equal(refused.status, 403); + assert.match(await refused.text(), /ConsoleAdminGrantMissing/); + assert.deepEqual(withoutCapability.calls, []); +}); + +test("Cognito sessions are signed, bounded, and mapped from groups", async () => { + const session = issueCognitoSession({ subject: "operator-2", groups: ["console-query"], expiresAt: Math.floor(Date.now() / 1000) + 60 }, "session-secret"); + const { request, calls } = (() => { + const calls: string[] = []; + const app = createConsole({ + identity: { kind: "cognito", sessionSecret: "session-secret", queryGroup: "console-query", adminGroup: "console-admin" }, + adminCapability: "server-secret", + stores: [], + turn: async () => ({ answer: "", sources: [], widgets: [] }), + read: { list: async () => [] }, + control: { workflows: async () => { calls.push("workflows"); return {}; }, record: async () => ({}), edit: async () => ({}), apply: async () => ({}) }, + }); + return { request: (path: string, cookie: string) => app.fetch(new Request(`https://console.example${path}`, { headers: { cookie } })), calls }; + })(); + assert.equal((await request("/query", `console_session=${session}`)).status, 200); + assert.equal((await request("/admin/api/workflows", `console_session=${session}`)).status, 403); + assert.equal((await request("/query", `console_session=${session}tampered`)).status, 403); + assert.deepEqual(calls, []); +}); From 80e79131bd98b6f93f851007017a1564bec757cc Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:42:05 +0800 Subject: [PATCH 02/14] feat(console): serve gated Query and Admin pages --- apps/console/pnpm-lock.yaml | 9 + apps/console/src/index.ts | 269 ++++++++++++++++++++++++++++++ apps/console/src/server.ts | 33 ++++ apps/console/test/console.test.ts | 90 +++++++++- 4 files changed, 396 insertions(+), 5 deletions(-) create mode 100644 apps/console/pnpm-lock.yaml create mode 100644 apps/console/src/index.ts create mode 100644 apps/console/src/server.ts diff --git a/apps/console/pnpm-lock.yaml b/apps/console/pnpm-lock.yaml new file mode 100644 index 000000000..9b60ae178 --- /dev/null +++ b/apps/console/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} diff --git a/apps/console/src/index.ts b/apps/console/src/index.ts new file mode 100644 index 000000000..1a6408c7a --- /dev/null +++ b/apps/console/src/index.ts @@ -0,0 +1,269 @@ +import { createHmac, createPublicKey, randomBytes, timingSafeEqual, verify, type KeyObject } from "node:crypto"; + +export type Operator = { subject: string; grants: ReadonlySet<"query" | "admin"> }; +export type Store = { id: string; label: string }; +export type AccessIdentity = { + kind: "access"; + issuer: string; + queryAudience: string; + adminAudience: string; + publicKey?: KeyObject | string; + keys?: ReadonlyMap; +}; +export type CognitoIdentity = { + kind: "cognito"; + sessionSecret: string; + queryGroup: string; + adminGroup: string; + issuer?: string; + clientId?: string; + authorizeUrl?: string; + tokenUrl?: string; + redirectUri?: string; + publicKey?: KeyObject | string; + fetcher?: typeof fetch; +}; +export type Identity = AccessIdentity | CognitoIdentity; +export type TurnInput = { operator: Operator; store: string; question: string }; +export type TurnResult = { answer: string; sources: unknown[]; widgets: unknown[] }; +export type PackList = { entries: unknown[]; truncated: boolean; declined: number }; +export type ConsoleAdapters = { + identity: Identity; + stores: Store[]; + adminCapability?: string; + turn: (input: TurnInput) => Promise; + read: { list: (operator: Operator) => Promise }; + control: { + workflows: (operator: Operator, store: string | null) => Promise; + record: (operator: Operator, store: string | null) => Promise; + edit: (document: unknown, capability: string, operator: Operator) => Promise; + apply: (document: unknown, capability: string, operator: Operator) => Promise; + listPackFiles?: (operator: Operator, store: string, prefix: string) => Promise; + }; +}; + +type Session = { subject: string; groups: string[]; expiresAt: number }; + +function encoded(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +function decode(value: string): unknown { + return JSON.parse(Buffer.from(value, "base64url").toString("utf8")); +} + +function object(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function secureEqual(left: Buffer, right: Buffer): boolean { + return left.length === right.length && timingSafeEqual(left, right); +} + +export function issueCognitoSession(session: Session, secret: string): string { + if (!secret || !session.subject || !Number.isSafeInteger(session.expiresAt) || session.expiresAt <= Math.floor(Date.now() / 1000)) { + throw new Error("CognitoSessionInvalid"); + } + const body = encoded(session); + const signature = createHmac("sha256", secret).update(body).digest("base64url"); + return `${body}.${signature}`; +} + +function verifyCognitoSession(cookie: string, identity: CognitoIdentity): Operator | null { + const token = cookie.split("; ").find((part) => part.startsWith("console_session="))?.slice("console_session=".length); + if (!token) return null; + const parts = token.split("."); + if (parts.length !== 2) return null; + const expected = createHmac("sha256", identity.sessionSecret).update(parts[0]).digest(); + let actual: Buffer; + try { actual = Buffer.from(parts[1], "base64url"); } catch { return null; } + if (!secureEqual(expected, actual)) return null; + let session: unknown; + try { session = decode(parts[0]); } catch { return null; } + if (!object(session) || typeof session.subject !== "string" || !session.subject || + !Number.isSafeInteger(session.expiresAt) || (session.expiresAt as number) <= Math.floor(Date.now() / 1000) || + !Array.isArray(session.groups) || !session.groups.every((group) => typeof group === "string")) return null; + const grants = new Set<"query" | "admin">(); + if (session.groups.includes(identity.queryGroup)) grants.add("query"); + if (session.groups.includes(identity.adminGroup)) grants.add("admin"); + return { subject: session.subject, grants }; +} + +function verifyAccess(assertion: string, identity: AccessIdentity): Operator | null { + const parts = assertion.split("."); + if (parts.length !== 3) return null; + let header: unknown; + let claims: unknown; + try { header = decode(parts[0]); claims = decode(parts[1]); } catch { return null; } + if (!object(header) || header.alg !== "RS256" || !object(claims)) return null; + if (claims.iss !== identity.issuer || typeof claims.sub !== "string" || !claims.sub || + !Number.isSafeInteger(claims.exp) || (claims.exp as number) <= Math.floor(Date.now() / 1000)) return null; + if (typeof claims.nbf === "number" && claims.nbf > Math.floor(Date.now() / 1000)) return null; + const audience = typeof claims.aud === "string" ? [claims.aud] : Array.isArray(claims.aud) ? claims.aud : []; + const grants = new Set<"query" | "admin">(); + if (audience.includes(identity.queryAudience)) grants.add("query"); + if (audience.includes(identity.adminAudience)) grants.add("admin"); + if (grants.size === 0) return null; + let signature: Buffer; + try { signature = Buffer.from(parts[2], "base64url"); } catch { return null; } + const key = identity.keys ? (typeof header.kid === "string" ? identity.keys.get(header.kid) : undefined) : + typeof identity.publicKey === "string" ? createPublicKey(identity.publicKey) : identity.publicKey; + if (!key) return null; + if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, signature)) return null; + return { subject: claims.sub, grants }; +} + +function operatorFor(request: Request, identity: Identity): Operator | null { + if (identity.kind === "access") { + const assertion = request.headers.get("cf-access-jwt-assertion"); + return assertion ? verifyAccess(assertion, identity) : null; + } + return verifyCognitoSession(request.headers.get("cookie") ?? "", identity); +} + +function cognitoLogin(identity: CognitoIdentity): Response { + if (!identity.authorizeUrl || !identity.clientId || !identity.redirectUri) return refusal("ConsoleLoginUnconfigured", 503); + const state = randomBytes(24).toString("base64url"); + const location = new URL(identity.authorizeUrl); + location.searchParams.set("response_type", "code"); + location.searchParams.set("client_id", identity.clientId); + location.searchParams.set("redirect_uri", identity.redirectUri); + location.searchParams.set("scope", "openid email profile"); + location.searchParams.set("state", state); + return new Response(null, { status: 302, headers: { + Location: location.toString(), + "Set-Cookie": `console_login_state=${state}; HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=300`, + "Cache-Control": "no-store", + } }); +} + +async function cognitoCallback(request: Request, identity: CognitoIdentity): Promise { + if (!identity.tokenUrl || !identity.clientId || !identity.redirectUri || !identity.issuer || !identity.publicKey) return refusal("ConsoleLoginUnconfigured", 503); + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const cookieState = request.headers.get("cookie")?.split(/;\s*/).find((part) => part.startsWith("console_login_state="))?.slice("console_login_state=".length); + if (!code || !state || !cookieState || !secureEqual(Buffer.from(state), Buffer.from(cookieState))) return refusal("ConsolePageForbidden"); + const exchange = await (identity.fetcher ?? fetch)(identity.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "authorization_code", client_id: identity.clientId, redirect_uri: identity.redirectUri, code }), + }); + if (!exchange.ok) return refusal("ConsolePageForbidden"); + const tokens: unknown = await exchange.json(); + if (!object(tokens) || typeof tokens.id_token !== "string") return refusal("ConsolePageForbidden"); + const parts = tokens.id_token.split("."); + if (parts.length !== 3) return refusal("ConsolePageForbidden"); + let header: unknown; + let claims: unknown; + try { header = decode(parts[0]); claims = decode(parts[1]); } catch { return refusal("ConsolePageForbidden"); } + if (!object(header) || header.alg !== "RS256" || !object(claims) || claims.iss !== identity.issuer || claims.aud !== identity.clientId || + typeof claims.sub !== "string" || !claims.sub || !Number.isSafeInteger(claims.exp) || (claims.exp as number) <= Math.floor(Date.now() / 1000) || + claims.token_use !== "id") return refusal("ConsolePageForbidden"); + const key = typeof identity.publicKey === "string" ? createPublicKey(identity.publicKey) : identity.publicKey; + if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"))) return refusal("ConsolePageForbidden"); + const groups = Array.isArray(claims["cognito:groups"]) ? claims["cognito:groups"].filter((group): group is string => typeof group === "string") : []; + const lifetime = Math.min(claims.exp as number, Math.floor(Date.now() / 1000) + 3600); + const session = issueCognitoSession({ subject: claims.sub, groups, expiresAt: lifetime }, identity.sessionSecret); + const destination = groups.includes(identity.adminGroup) ? "/admin" : "/query"; + return new Response(null, { status: 302, headers: { + Location: destination, + "Set-Cookie": `console_session=${session}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${lifetime - Math.floor(Date.now() / 1000)}`, + "Cache-Control": "no-store", + } }); +} + +function refusal(identifier: string, status = 403): Response { + return Response.json({ error: { identifier } }, { status, headers: { "Cache-Control": "no-store" } }); +} + +function json(value: unknown): Response { + return Response.json(value, { headers: { "Cache-Control": "no-store" } }); +} + +async function body(request: Request): Promise { + if (Number(request.headers.get("content-length") ?? 0) > 1_048_576) throw new Error("body too large"); + const text = await request.text(); + if (text.length > 1_048_576) throw new Error("body too large"); + return JSON.parse(text); +} + +function page(name: "query" | "admin"): Response { + return new Response(name === "query" ? queryPage : adminPage, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + "X-Content-Type-Options": "nosniff", + }, + }); +} + +export function createConsole(adapters: ConsoleAdapters): { fetch: (request: Request) => Promise } { + return { + async fetch(request) { + const url = new URL(request.url); + const path = url.pathname; + if (adapters.identity.kind === "cognito") { + if (path === "/auth/login" && request.method === "GET") return cognitoLogin(adapters.identity); + if (path === "/auth/callback" && request.method === "GET") return cognitoCallback(request, adapters.identity); + } + const grant = path === "/query" || path.startsWith("/query/api/") ? "query" : + path === "/admin" || path.startsWith("/admin/api/") ? "admin" : null; + if (!grant) return refusal("ConsoleRouteNotFound", 404); + let operator: Operator | null; + try { operator = operatorFor(request, adapters.identity); } catch { operator = null; } + if (!operator) return refusal("ConsolePageForbidden", 401); + if (!operator.grants.has(grant)) return refusal("ConsolePageForbidden"); + if (adapters.identity.kind === "cognito" && request.method === "POST" && request.headers.get("origin") !== url.origin) { + return refusal("ConsolePageForbidden"); + } + if (request.method === "GET" && path === `/${grant}`) return page(grant); + if (grant === "query") { + if (request.method === "GET" && path === "/query/api/stores") return json(await adapters.read.list(operator)); + if (request.method === "POST" && path === "/query/api/ask") { + let input: unknown; + try { input = await body(request); } catch { return refusal("ConsoleRequestMalformed", 400); } + if (!object(input) || typeof input.store !== "string" || typeof input.question !== "string" || !input.question.trim() || + !adapters.stores.some((store) => store.id === input.store)) return refusal("ConsoleRequestMalformed", 400); + return json(await adapters.turn({ operator, store: input.store, question: input.question })); + } + } else { + const store = url.searchParams.get("store"); + if (request.method === "GET" && path === "/admin/api/workflows") return json(await adapters.control.workflows(operator, store)); + if (request.method === "GET" && path === "/admin/api/record") return json(await adapters.control.record(operator, store)); + if (request.method === "GET" && path === "/admin/api/packs") { + if (!store || !adapters.stores.some((entry) => entry.id === store)) return refusal("ConsoleRequestMalformed", 400); + if (!adapters.control.listPackFiles) return refusal("ConsoleAdapterUnavailable", 503); + return json(await adapters.control.listPackFiles(operator, store, url.searchParams.get("prefix") ?? "")); + } + if (request.method === "POST" && (path === "/admin/api/edit" || path === "/admin/api/apply")) { + if (!adapters.adminCapability) return refusal("ConsoleAdminGrantMissing"); + const origin = request.headers.get("origin"); + if (origin && origin !== url.origin) return refusal("ConsolePageForbidden"); + let document: unknown; + try { document = await body(request); } catch { return refusal("ConsoleRequestMalformed", 400); } + return json(path.endsWith("/edit") ? + await adapters.control.edit(document, adapters.adminCapability, operator) : + await adapters.control.apply(document, adapters.adminCapability, operator)); + } + } + return refusal("ConsoleRouteNotFound", 404); + }, + }; +} + +const sharedStyle = ``; + +const queryPage = `Query · Contextful${sharedStyle}

Ask the store

Answers cite the rows your access permits.

Conversation

Your answer appears here.

Results

`; + +const adminPage = `Admin · Contextful${sharedStyle}

Store operations

Pipelines, schedules, steps and run outcomes come from the store.

Workflow canvas

Operational record

Control document

`; diff --git a/apps/console/src/server.ts b/apps/console/src/server.ts new file mode 100644 index 000000000..3cdc0fe77 --- /dev/null +++ b/apps/console/src/server.ts @@ -0,0 +1,33 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { ConsoleAdapters } from "./index.ts"; +import { createConsole } from "./index.ts"; + +async function receive(message: IncomingMessage, origin: string): Promise { + const method = message.method ?? "GET"; + const chunks: Buffer[] = []; + for await (const chunk of message) chunks.push(Buffer.from(chunk)); + const body = method === "GET" || method === "HEAD" ? undefined : Buffer.concat(chunks); + const headers = new Headers(); + for (const [key, value] of Object.entries(message.headers)) { + if (typeof value === "string") headers.set(key, value); + else if (Array.isArray(value)) headers.set(key, value.join(", ")); + } + return new Request(new URL(message.url ?? "/", origin), { method, headers, body }); +} + +async function send(reply: ServerResponse, response: Response): Promise { + reply.statusCode = response.status; + response.headers.forEach((value, key) => reply.setHeader(key, value)); + reply.end(Buffer.from(await response.arrayBuffer())); +} + +export function serveConsole(adapters: ConsoleAdapters, origin: string): Server { + const app = createConsole(adapters); + return createServer(async (message, reply) => { + try { + await send(reply, await app.fetch(await receive(message, origin))); + } catch { + await send(reply, Response.json({ error: { identifier: "ConsoleServerFailure" } }, { status: 500 })); + } + }); +} diff --git a/apps/console/test/console.test.ts b/apps/console/test/console.test.ts index d60e56640..b92fbb7ca 100644 --- a/apps/console/test/console.test.ts +++ b/apps/console/test/console.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { generateKeyPairSync, sign } from "node:crypto"; import { createConsole, issueCognitoSession } from "../src/index.ts"; +import { serveConsole } from "../src/server.ts"; const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const issuer = "https://access.example"; @@ -43,8 +44,8 @@ function fixture(adminCapability = "server-secret") { test("anonymous and forged Access assertions dispatch no page or API", async () => { const { request, calls } = fixture(); for (const path of ["/query", "/query/api/stores", "/admin", "/admin/api/workflows"]) { - assert.equal((await request(path)).status, 403); - assert.equal((await request(path, { "cf-access-jwt-assertion": "forged" })).status, 403); + assert.equal((await request(path)).status, 401); + assert.equal((await request(path, { "cf-access-jwt-assertion": "forged" })).status, 401); } assert.deepEqual(calls, []); }); @@ -63,8 +64,8 @@ test("verified Access audiences grant only their page and API", async () => { test("expired or wrong-issuer assertions cannot open a page", async () => { const { request } = fixture(); - assert.equal((await request("/query", access(queryAudience, { exp: 1 }))).status, 403); - assert.equal((await request("/query", access(queryAudience, { iss: "https://other.example" }))).status, 403); + assert.equal((await request("/query", access(queryAudience, { exp: 1 }))).status, 401); + assert.equal((await request("/query", access(queryAudience, { iss: "https://other.example" }))).status, 401); }); test("Query keeps one composer and transcript and delegates a sourced answer", async () => { @@ -111,6 +112,85 @@ test("Cognito sessions are signed, bounded, and mapped from groups", async () => })(); assert.equal((await request("/query", `console_session=${session}`)).status, 200); assert.equal((await request("/admin/api/workflows", `console_session=${session}`)).status, 403); - assert.equal((await request("/query", `console_session=${session}tampered`)).status, 403); + assert.equal((await request("/query", `console_session=${session}tampered`)).status, 401); assert.deepEqual(calls, []); }); + +test("Cognito hosted login exchanges a code before issuing a first-party session", async () => { + const app = createConsole({ + identity: { + kind: "cognito", sessionSecret: "session-secret", queryGroup: "console-query", adminGroup: "console-admin", + issuer: "https://cognito.example/pool", clientId: "console-client", publicKey, + authorizeUrl: "https://cognito.example/oauth2/authorize", tokenUrl: "https://cognito.example/oauth2/token", + redirectUri: "https://console.example/auth/callback", + fetcher: async (_url, init) => { + assert.equal(init?.method, "POST"); + assert.match(String(init?.body), /code=accepted/); + return Response.json({ id_token: jwt({ iss: "https://cognito.example/pool", aud: "console-client", sub: "operator-3", token_use: "id", "cognito:groups": ["console-admin"], exp: Math.floor(Date.now() / 1000) + 300 }) }); + }, + }, + stores: [], turn: async () => ({ answer: "", sources: [], widgets: [] }), read: { list: async () => [] }, + control: { workflows: async () => ({}), record: async () => ({}), edit: async () => ({}), apply: async () => ({}) }, + }); + const login = await app.fetch(new Request("https://console.example/auth/login")); + assert.equal(login.status, 302); + const location = new URL(login.headers.get("location")!); + const state = location.searchParams.get("state"); + assert.ok(state); + const stateCookie = login.headers.get("set-cookie")!.split(";")[0]; + const callback = await app.fetch(new Request(`https://console.example/auth/callback?code=accepted&state=${state}`, { headers: { cookie: stateCookie } })); + assert.equal(callback.status, 302); + assert.equal(callback.headers.get("location"), "/admin"); + const sessionCookie = callback.headers.get("set-cookie")!.split(";")[0]; + assert.equal((await app.fetch(new Request("https://console.example/admin", { headers: { cookie: sessionCookie } }))).status, 200); + assert.equal((await app.fetch(new Request("https://console.example/query", { headers: { cookie: sessionCookie } }))).status, 403); + const badState = await app.fetch(new Request("https://console.example/auth/callback?code=accepted&state=wrong", { headers: { cookie: stateCookie } })); + assert.equal(badState.status, 403); +}); + +test("the Node HTTP listener verifies a page request before serving content", async () => { + const app = createConsole({ + identity: { kind: "access", issuer, queryAudience, adminAudience, publicKey }, + stores: [], + turn: async () => ({ answer: "", sources: [], widgets: [] }), + read: { list: async () => [] }, + control: { workflows: async () => ({}), record: async () => ({}), edit: async () => ({}), apply: async () => ({}) }, + }); + assert.equal((await app.fetch(new Request("https://console.example/query"))).status, 401); + const server = serveConsole({ + identity: { kind: "access", issuer, queryAudience, adminAudience, publicKey }, + stores: [], + turn: async () => ({ answer: "", sources: [], widgets: [] }), + read: { list: async () => [] }, + control: { workflows: async () => ({}), record: async () => ({}), edit: async () => ({}), apply: async () => ({}) }, + }, "http://127.0.0.1"); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const url = `http://127.0.0.1:${address.port}`; + assert.equal((await fetch(`${url}/query`)).status, 401); + assert.equal((await fetch(`${url}/query`, { headers: access(queryAudience) })).status, 200); + } finally { + server.close(); + } +}); + +test("Admin pack listing reaches only the injected listing adapter", async () => { + const calls: string[] = []; + const app = createConsole({ + identity: { kind: "access", issuer, queryAudience, adminAudience, publicKey }, + stores: [{ id: "field-notes", label: "Field notes" }], + turn: async () => ({ answer: "", sources: [], widgets: [] }), + read: { list: async () => [] }, + control: { + workflows: async () => ({}), record: async () => ({}), edit: async () => ({}), apply: async () => ({}), + listPackFiles: async (_operator, store, prefix) => { calls.push(`${store}:${prefix}`); return { entries: ["packs/a.toml"], truncated: false, declined: 2 }; }, + }, + }); + const url = "https://console.example/admin/api/packs?store=field-notes&prefix=packs/"; + assert.equal((await app.fetch(new Request(url, { headers: access(queryAudience) }))).status, 403); + const response = await app.fetch(new Request(url, { headers: access(adminAudience) })); + assert.deepEqual(await response.json(), { entries: ["packs/a.toml"], truncated: false, declined: 2 }); + assert.deepEqual(calls, ["field-notes:packs/"]); +}); From 20cee1fa8ae5db0896332eb4c8022ea9592bc3eb Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:46:30 +0800 Subject: [PATCH 03/14] build(console): typecheck and bundle hosted server --- apps/console/dist/server.js | 442 ++++++++++++++++++++++++++++++++++++ apps/console/package.json | 9 +- apps/console/pnpm-lock.yaml | 303 +++++++++++++++++++++++- apps/console/src/entry.ts | 112 +++++++++ apps/console/src/server.ts | 5 +- apps/console/tsconfig.json | 13 ++ 6 files changed, 880 insertions(+), 4 deletions(-) create mode 100644 apps/console/dist/server.js create mode 100644 apps/console/src/entry.ts create mode 100644 apps/console/tsconfig.json diff --git a/apps/console/dist/server.js b/apps/console/dist/server.js new file mode 100644 index 000000000..63c3a70bf --- /dev/null +++ b/apps/console/dist/server.js @@ -0,0 +1,442 @@ +// src/entry.ts +import { createPublicKey as createPublicKey2 } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { isAbsolute, resolve } from "node:path"; + +// ../gateway/src/index.ts +var RegistryError = class extends Error { + identifier; + constructor(identifier, message) { + super(`${identifier}: ${message}`); + this.identifier = identifier; + this.name = identifier; + } +}; +var RESERVED_IDS = /* @__PURE__ */ new Set(["admin", "query"]); +var ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; +function names(id) { + const bindingName = id.replaceAll("-", "_").toUpperCase(); + return { bindingName, credentialName: `${bindingName}_QUERY_TOKEN` }; +} +function malformed(index) { + return { identifier: "StoreEntryMalformed", entry: index }; +} +function decodeEntry(value, index) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw malformed(index); + const raw = value; + if (typeof raw.id !== "string" || !ID.test(raw.id)) throw malformed(index); + if (RESERVED_IDS.has(raw.id)) throw new RegistryError("StoreIdReserved", raw.id); + for (const key of ["credential", "credentialName", "binding", "bindingName"]) { + if (key in raw) throw new RegistryError("StoreNameAuthored", key); + } + if (typeof raw.endpoint !== "string") throw malformed(index); + let endpoint; + try { + endpoint = new URL(raw.endpoint); + } catch { + throw malformed(index); + } + if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") throw malformed(index); + for (const key of ["label", "auth", "exchangeRoute", "packPrefix"]) { + if (key in raw && typeof raw[key] !== "string") throw malformed(index); + } + const { credentialName, bindingName } = names(raw.id); + return { + id: raw.id, + label: typeof raw.label === "string" ? raw.label : raw.id, + endpoint: endpoint.toString().replace(/\/$/, ""), + auth: typeof raw.auth === "string" ? raw.auth : void 0, + exchangeRoute: typeof raw.exchangeRoute === "string" ? raw.exchangeRoute : void 0, + packPrefix: typeof raw.packPrefix === "string" ? raw.packPrefix : void 0, + credentialName, + bindingName + }; +} +function registryFromEnv(raw) { + let parsed; + try { + parsed = JSON.parse(raw ?? "[]"); + } catch (error) { + throw new RegistryError("StoreRegistryUnreadable", error instanceof Error ? error.message : "invalid JSON"); + } + if (!Array.isArray(parsed)) throw new RegistryError("StoreRegistryUnreadable", "expected an array"); + const entries = []; + const problems = []; + for (const [index, value] of parsed.entries()) { + try { + entries.push(decodeEntry(value, index)); + } catch (error) { + if (error instanceof RegistryError && (error.identifier === "StoreIdReserved" || error.identifier === "StoreNameAuthored")) throw error; + problems.push(malformed(index)); + } + } + return { entries, problems }; +} + +// src/server.ts +import { createServer } from "node:http"; + +// src/index.ts +import { createHmac, createPublicKey, randomBytes, timingSafeEqual, verify } from "node:crypto"; +function encoded(value) { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} +function decode(value) { + return JSON.parse(Buffer.from(value, "base64url").toString("utf8")); +} +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} +function secureEqual(left, right) { + return left.length === right.length && timingSafeEqual(left, right); +} +function issueCognitoSession(session, secret) { + if (!secret || !session.subject || !Number.isSafeInteger(session.expiresAt) || session.expiresAt <= Math.floor(Date.now() / 1e3)) { + throw new Error("CognitoSessionInvalid"); + } + const body2 = encoded(session); + const signature = createHmac("sha256", secret).update(body2).digest("base64url"); + return `${body2}.${signature}`; +} +function verifyCognitoSession(cookie, identity2) { + const token = cookie.split("; ").find((part) => part.startsWith("console_session="))?.slice("console_session=".length); + if (!token) return null; + const parts = token.split("."); + if (parts.length !== 2) return null; + const expected = createHmac("sha256", identity2.sessionSecret).update(parts[0]).digest(); + let actual; + try { + actual = Buffer.from(parts[1], "base64url"); + } catch { + return null; + } + if (!secureEqual(expected, actual)) return null; + let session; + try { + session = decode(parts[0]); + } catch { + return null; + } + if (!object(session) || typeof session.subject !== "string" || !session.subject || !Number.isSafeInteger(session.expiresAt) || session.expiresAt <= Math.floor(Date.now() / 1e3) || !Array.isArray(session.groups) || !session.groups.every((group) => typeof group === "string")) return null; + const grants = /* @__PURE__ */ new Set(); + if (session.groups.includes(identity2.queryGroup)) grants.add("query"); + if (session.groups.includes(identity2.adminGroup)) grants.add("admin"); + return { subject: session.subject, grants }; +} +function verifyAccess(assertion, identity2) { + const parts = assertion.split("."); + if (parts.length !== 3) return null; + let header; + let claims; + try { + header = decode(parts[0]); + claims = decode(parts[1]); + } catch { + return null; + } + if (!object(header) || header.alg !== "RS256" || !object(claims)) return null; + if (claims.iss !== identity2.issuer || typeof claims.sub !== "string" || !claims.sub || !Number.isSafeInteger(claims.exp) || claims.exp <= Math.floor(Date.now() / 1e3)) return null; + if (typeof claims.nbf === "number" && claims.nbf > Math.floor(Date.now() / 1e3)) return null; + const audience = typeof claims.aud === "string" ? [claims.aud] : Array.isArray(claims.aud) ? claims.aud : []; + const grants = /* @__PURE__ */ new Set(); + if (audience.includes(identity2.queryAudience)) grants.add("query"); + if (audience.includes(identity2.adminAudience)) grants.add("admin"); + if (grants.size === 0) return null; + let signature; + try { + signature = Buffer.from(parts[2], "base64url"); + } catch { + return null; + } + const key = identity2.keys ? typeof header.kid === "string" ? identity2.keys.get(header.kid) : void 0 : typeof identity2.publicKey === "string" ? createPublicKey(identity2.publicKey) : identity2.publicKey; + if (!key) return null; + if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, signature)) return null; + return { subject: claims.sub, grants }; +} +function operatorFor(request, identity2) { + if (identity2.kind === "access") { + const assertion = request.headers.get("cf-access-jwt-assertion"); + return assertion ? verifyAccess(assertion, identity2) : null; + } + return verifyCognitoSession(request.headers.get("cookie") ?? "", identity2); +} +function cognitoLogin(identity2) { + if (!identity2.authorizeUrl || !identity2.clientId || !identity2.redirectUri) return refusal("ConsoleLoginUnconfigured", 503); + const state = randomBytes(24).toString("base64url"); + const location = new URL(identity2.authorizeUrl); + location.searchParams.set("response_type", "code"); + location.searchParams.set("client_id", identity2.clientId); + location.searchParams.set("redirect_uri", identity2.redirectUri); + location.searchParams.set("scope", "openid email profile"); + location.searchParams.set("state", state); + return new Response(null, { status: 302, headers: { + Location: location.toString(), + "Set-Cookie": `console_login_state=${state}; HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=300`, + "Cache-Control": "no-store" + } }); +} +async function cognitoCallback(request, identity2) { + if (!identity2.tokenUrl || !identity2.clientId || !identity2.redirectUri || !identity2.issuer || !identity2.publicKey) return refusal("ConsoleLoginUnconfigured", 503); + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const cookieState = request.headers.get("cookie")?.split(/;\s*/).find((part) => part.startsWith("console_login_state="))?.slice("console_login_state=".length); + if (!code || !state || !cookieState || !secureEqual(Buffer.from(state), Buffer.from(cookieState))) return refusal("ConsolePageForbidden"); + const exchange = await (identity2.fetcher ?? fetch)(identity2.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "authorization_code", client_id: identity2.clientId, redirect_uri: identity2.redirectUri, code }) + }); + if (!exchange.ok) return refusal("ConsolePageForbidden"); + const tokens = await exchange.json(); + if (!object(tokens) || typeof tokens.id_token !== "string") return refusal("ConsolePageForbidden"); + const parts = tokens.id_token.split("."); + if (parts.length !== 3) return refusal("ConsolePageForbidden"); + let header; + let claims; + try { + header = decode(parts[0]); + claims = decode(parts[1]); + } catch { + return refusal("ConsolePageForbidden"); + } + if (!object(header) || header.alg !== "RS256" || !object(claims) || claims.iss !== identity2.issuer || claims.aud !== identity2.clientId || typeof claims.sub !== "string" || !claims.sub || !Number.isSafeInteger(claims.exp) || claims.exp <= Math.floor(Date.now() / 1e3) || claims.token_use !== "id") return refusal("ConsolePageForbidden"); + const key = typeof identity2.publicKey === "string" ? createPublicKey(identity2.publicKey) : identity2.publicKey; + if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"))) return refusal("ConsolePageForbidden"); + const groups = Array.isArray(claims["cognito:groups"]) ? claims["cognito:groups"].filter((group) => typeof group === "string") : []; + const lifetime = Math.min(claims.exp, Math.floor(Date.now() / 1e3) + 3600); + const session = issueCognitoSession({ subject: claims.sub, groups, expiresAt: lifetime }, identity2.sessionSecret); + const destination = groups.includes(identity2.adminGroup) ? "/admin" : "/query"; + return new Response(null, { status: 302, headers: { + Location: destination, + "Set-Cookie": `console_session=${session}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${lifetime - Math.floor(Date.now() / 1e3)}`, + "Cache-Control": "no-store" + } }); +} +function refusal(identifier, status = 403) { + return Response.json({ error: { identifier } }, { status, headers: { "Cache-Control": "no-store" } }); +} +function json(value) { + return Response.json(value, { headers: { "Cache-Control": "no-store" } }); +} +async function body(request) { + if (Number(request.headers.get("content-length") ?? 0) > 1048576) throw new Error("body too large"); + const text = await request.text(); + if (text.length > 1048576) throw new Error("body too large"); + return JSON.parse(text); +} +function page(name) { + return new Response(name === "query" ? queryPage : adminPage, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + "X-Content-Type-Options": "nosniff" + } + }); +} +function createConsole(adapters) { + return { + async fetch(request) { + const url = new URL(request.url); + const path = url.pathname; + if (adapters.identity.kind === "cognito") { + if (path === "/auth/login" && request.method === "GET") return cognitoLogin(adapters.identity); + if (path === "/auth/callback" && request.method === "GET") return cognitoCallback(request, adapters.identity); + } + const grant = path === "/query" || path.startsWith("/query/api/") ? "query" : path === "/admin" || path.startsWith("/admin/api/") ? "admin" : null; + if (!grant) return refusal("ConsoleRouteNotFound", 404); + let operator; + try { + operator = operatorFor(request, adapters.identity); + } catch { + operator = null; + } + if (!operator) return refusal("ConsolePageForbidden", 401); + if (!operator.grants.has(grant)) return refusal("ConsolePageForbidden"); + if (adapters.identity.kind === "cognito" && request.method === "POST" && request.headers.get("origin") !== url.origin) { + return refusal("ConsolePageForbidden"); + } + if (request.method === "GET" && path === `/${grant}`) return page(grant); + if (grant === "query") { + if (request.method === "GET" && path === "/query/api/stores") return json(await adapters.read.list(operator)); + if (request.method === "POST" && path === "/query/api/ask") { + let input; + try { + input = await body(request); + } catch { + return refusal("ConsoleRequestMalformed", 400); + } + if (!object(input) || typeof input.store !== "string" || typeof input.question !== "string" || !input.question.trim() || !adapters.stores.some((store) => store.id === input.store)) return refusal("ConsoleRequestMalformed", 400); + return json(await adapters.turn({ operator, store: input.store, question: input.question })); + } + } else { + const store = url.searchParams.get("store"); + if (request.method === "GET" && path === "/admin/api/workflows") return json(await adapters.control.workflows(operator, store)); + if (request.method === "GET" && path === "/admin/api/record") return json(await adapters.control.record(operator, store)); + if (request.method === "GET" && path === "/admin/api/packs") { + if (!store || !adapters.stores.some((entry) => entry.id === store)) return refusal("ConsoleRequestMalformed", 400); + if (!adapters.control.listPackFiles) return refusal("ConsoleAdapterUnavailable", 503); + return json(await adapters.control.listPackFiles(operator, store, url.searchParams.get("prefix") ?? "")); + } + if (request.method === "POST" && (path === "/admin/api/edit" || path === "/admin/api/apply")) { + if (!adapters.adminCapability) return refusal("ConsoleAdminGrantMissing"); + const origin = request.headers.get("origin"); + if (origin && origin !== url.origin) return refusal("ConsolePageForbidden"); + let document; + try { + document = await body(request); + } catch { + return refusal("ConsoleRequestMalformed", 400); + } + return json(path.endsWith("/edit") ? await adapters.control.edit(document, adapters.adminCapability, operator) : await adapters.control.apply(document, adapters.adminCapability, operator)); + } + } + return refusal("ConsoleRouteNotFound", 404); + } + }; +} +var sharedStyle = ``; +var queryPage = `Query \xB7 Contextful${sharedStyle}

Ask the store

Answers cite the rows your access permits.

Conversation

Your answer appears here.

Results

`; +var adminPage = `Admin \xB7 Contextful${sharedStyle}

Store operations

Pipelines, schedules, steps and run outcomes come from the store.

Workflow canvas

Operational record

Control document

`; + +// src/server.ts +async function receive(message, origin) { + const method = message.method ?? "GET"; + const chunks = []; + for await (const chunk of message) chunks.push(Buffer.from(chunk)); + const body2 = method === "GET" || method === "HEAD" ? void 0 : Buffer.concat(chunks); + const headers = new Headers(); + for (const [key, value] of Object.entries(message.headers)) { + if (typeof value === "string") headers.set(key, value); + else if (Array.isArray(value)) headers.set(key, value.join(", ")); + } + return new Request(new URL(message.url ?? "/", origin), { method, headers, body: body2 }); +} +async function send(reply, response) { + reply.statusCode = response.status; + response.headers.forEach((value, key) => reply.setHeader(key, value)); + reply.end(Buffer.from(await response.arrayBuffer())); +} +function serveConsole(adapters, origin) { + const app = createConsole(adapters); + return createServer(async (message, reply) => { + try { + await send(reply, await app.fetch(await receive(message, origin))); + } catch (error) { + const unavailable2 = error instanceof Error && error.message === "ConsoleAdapterUnavailable"; + await send(reply, Response.json({ error: { identifier: unavailable2 ? "ConsoleAdapterUnavailable" : "ConsoleServerFailure" } }, { status: unavailable2 ? 503 : 500 })); + } + }); +} + +// src/entry.ts +function required(name) { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} +async function keysAt(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`identity key route returned ${response.status}`); + const document = await response.json(); + if (!document || typeof document !== "object" || !("keys" in document) || !Array.isArray(document.keys)) { + throw new Error("identity key route returned no key set"); + } + const keys = /* @__PURE__ */ new Map(); + for (const value of document.keys) { + if (!value || typeof value !== "object" || value.kty !== "RSA" || value.alg !== "RS256" || typeof value.kid !== "string") continue; + keys.set(value.kid, createPublicKey2({ key: value, format: "jwk" })); + } + if (keys.size === 0) throw new Error("identity key route returned no RS256 key"); + return keys; +} +async function identity() { + if (process.env.CONTEXTFUL_IDENTITY === "cognito") { + const keys = await keysAt(required("CONTEXTFUL_COGNITO_JWKS_URL")); + if (keys.size !== 1) throw new Error("Cognito key route needs one pinned key"); + return { + kind: "cognito", + sessionSecret: required("CONTEXTFUL_COGNITO_SESSION_SECRET"), + queryGroup: required("CONTEXTFUL_COGNITO_QUERY_GROUP"), + adminGroup: required("CONTEXTFUL_COGNITO_ADMIN_GROUP"), + issuer: required("CONTEXTFUL_COGNITO_ISSUER"), + clientId: required("CONTEXTFUL_COGNITO_CLIENT_ID"), + authorizeUrl: required("CONTEXTFUL_COGNITO_AUTHORIZE_URL"), + tokenUrl: required("CONTEXTFUL_COGNITO_TOKEN_URL"), + redirectUri: required("CONTEXTFUL_COGNITO_REDIRECT_URI"), + publicKey: [...keys.values()][0] + }; + } + return { + kind: "access", + issuer: required("CONTEXTFUL_ACCESS_ISSUER"), + queryAudience: required("CONTEXTFUL_QUERY_ACCESS_AUDIENCE"), + adminAudience: required("CONTEXTFUL_ADMIN_ACCESS_AUDIENCE"), + keys: await keysAt(required("CONTEXTFUL_ACCESS_JWKS_URL")) + }; +} +function unavailable() { + throw new Error("ConsoleAdapterUnavailable"); +} +function unavailableAdapters() { + return { + turn: async () => unavailable(), + control: { + workflows: async () => unavailable(), + record: async () => unavailable(), + edit: async () => unavailable(), + apply: async () => unavailable() + } + }; +} +async function main() { + const addressFlag = process.argv.indexOf("--http"); + if (addressFlag < 0 || !process.argv[addressFlag + 1]) throw new Error("--http is required"); + const address = process.argv[addressFlag + 1]; + const separator = address.lastIndexOf(":"); + const host = address.slice(0, separator); + const port = Number(address.slice(separator + 1)); + if (!host || !Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid --http address"); + const registry = registryFromEnv(process.env.CONTEXTFUL_STORES_JSON); + const stores = registry.entries.map(({ id, label }) => ({ id, label })); + let adapters = unavailableAdapters(); + const modulePath = process.env.CONTEXTFUL_CONSOLE_ADAPTER_MODULE; + if (modulePath) { + const absolute = isAbsolute(modulePath) ? modulePath : resolve(modulePath); + const module = await import(pathToFileURL(absolute).href); + if (!module.createAdapters) throw new Error("console adapter module exports no createAdapters"); + adapters = await module.createAdapters({ stores, env: process.env }); + } + const app = serveConsole({ + identity: await identity(), + stores, + adminCapability: process.env.CONTEXTFUL_ADMIN_CAPABILITY, + turn: adapters.turn, + read: adapters.read ?? { list: async () => stores }, + control: adapters.control + }, process.env.CONTEXTFUL_CONSOLE_ORIGIN ?? `http://${host}:${port}`); + await new Promise((resolveListen, rejectListen) => { + app.once("error", rejectListen); + app.listen(port, host, resolveListen); + }); + const bound = app.address(); + if (!bound || typeof bound === "string") throw new Error("console listener has no address"); + process.stderr.write(`listening on http://${host}:${bound.port} +`); +} +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : "console startup failed"} +`); + process.exitCode = 1; +}); diff --git a/apps/console/package.json b/apps/console/package.json index ac1f158a9..8b68b29ef 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -3,6 +3,13 @@ "private": true, "type": "module", "scripts": { - "test": "node --experimental-strip-types --test test/*.test.ts" + "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types --test test/*.test.ts", + "build": "esbuild src/entry.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/server.js" + }, + "devDependencies": { + "@types/node": "22.20.4", + "esbuild": "0.25.12", + "typescript": "6.0.3" } } diff --git a/apps/console/pnpm-lock.yaml b/apps/console/pnpm-lock.yaml index 9b60ae178..63aff606d 100644 --- a/apps/console/pnpm-lock.yaml +++ b/apps/console/pnpm-lock.yaml @@ -6,4 +6,305 @@ settings: importers: - .: {} + .: + devDependencies: + '@types/node': + specifier: 22.20.4 + version: 22.20.4 + esbuild: + specifier: 0.25.12 + version: 0.25.12 + typescript: + specifier: 6.0.3 + version: 6.0.3 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@types/node@22.20.4': + dependencies: + undici-types: 6.21.0 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + typescript@6.0.3: {} + + undici-types@6.21.0: {} diff --git a/apps/console/src/entry.ts b/apps/console/src/entry.ts new file mode 100644 index 000000000..59b892446 --- /dev/null +++ b/apps/console/src/entry.ts @@ -0,0 +1,112 @@ +import { createPublicKey } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { isAbsolute, resolve } from "node:path"; +import { registryFromEnv } from "../../gateway/src/index.ts"; +import type { ConsoleAdapters, Identity } from "./index.ts"; +import { serveConsole } from "./server.ts"; + +type AdapterFactory = (input: { stores: ConsoleAdapters["stores"]; env: NodeJS.ProcessEnv }) => Promise & { read?: ConsoleAdapters["read"] }>; + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required`); + return value; +} + +async function keysAt(url: string): Promise>> { + const response = await fetch(url); + if (!response.ok) throw new Error(`identity key route returned ${response.status}`); + const document: unknown = await response.json(); + if (!document || typeof document !== "object" || !("keys" in document) || !Array.isArray(document.keys)) { + throw new Error("identity key route returned no key set"); + } + const keys = new Map>(); + for (const value of document.keys) { + if (!value || typeof value !== "object" || value.kty !== "RSA" || value.alg !== "RS256" || typeof value.kid !== "string") continue; + keys.set(value.kid, createPublicKey({ key: value, format: "jwk" })); + } + if (keys.size === 0) throw new Error("identity key route returned no RS256 key"); + return keys; +} + +async function identity(): Promise { + if (process.env.CONTEXTFUL_IDENTITY === "cognito") { + const keys = await keysAt(required("CONTEXTFUL_COGNITO_JWKS_URL")); + if (keys.size !== 1) throw new Error("Cognito key route needs one pinned key"); + return { + kind: "cognito", + sessionSecret: required("CONTEXTFUL_COGNITO_SESSION_SECRET"), + queryGroup: required("CONTEXTFUL_COGNITO_QUERY_GROUP"), + adminGroup: required("CONTEXTFUL_COGNITO_ADMIN_GROUP"), + issuer: required("CONTEXTFUL_COGNITO_ISSUER"), + clientId: required("CONTEXTFUL_COGNITO_CLIENT_ID"), + authorizeUrl: required("CONTEXTFUL_COGNITO_AUTHORIZE_URL"), + tokenUrl: required("CONTEXTFUL_COGNITO_TOKEN_URL"), + redirectUri: required("CONTEXTFUL_COGNITO_REDIRECT_URI"), + publicKey: [...keys.values()][0], + }; + } + return { + kind: "access", + issuer: required("CONTEXTFUL_ACCESS_ISSUER"), + queryAudience: required("CONTEXTFUL_QUERY_ACCESS_AUDIENCE"), + adminAudience: required("CONTEXTFUL_ADMIN_ACCESS_AUDIENCE"), + keys: await keysAt(required("CONTEXTFUL_ACCESS_JWKS_URL")), + }; +} + +function unavailable(): never { + throw new Error("ConsoleAdapterUnavailable"); +} + +function unavailableAdapters(): Pick { + return { + turn: async () => unavailable(), + control: { + workflows: async () => unavailable(), + record: async () => unavailable(), + edit: async () => unavailable(), + apply: async () => unavailable(), + }, + }; +} + +async function main(): Promise { + const addressFlag = process.argv.indexOf("--http"); + if (addressFlag < 0 || !process.argv[addressFlag + 1]) throw new Error("--http is required"); + const address = process.argv[addressFlag + 1]; + const separator = address.lastIndexOf(":"); + const host = address.slice(0, separator); + const port = Number(address.slice(separator + 1)); + if (!host || !Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid --http address"); + const registry = registryFromEnv(process.env.CONTEXTFUL_STORES_JSON); + const stores = registry.entries.map(({ id, label }) => ({ id, label })); + let adapters: Pick & { read?: ConsoleAdapters["read"] } = unavailableAdapters(); + const modulePath = process.env.CONTEXTFUL_CONSOLE_ADAPTER_MODULE; + if (modulePath) { + const absolute = isAbsolute(modulePath) ? modulePath : resolve(modulePath); + const module: { createAdapters?: AdapterFactory } = await import(pathToFileURL(absolute).href); + if (!module.createAdapters) throw new Error("console adapter module exports no createAdapters"); + adapters = await module.createAdapters({ stores, env: process.env }); + } + const app = serveConsole({ + identity: await identity(), + stores, + adminCapability: process.env.CONTEXTFUL_ADMIN_CAPABILITY, + turn: adapters.turn, + read: adapters.read ?? { list: async () => stores }, + control: adapters.control, + }, process.env.CONTEXTFUL_CONSOLE_ORIGIN ?? `http://${host}:${port}`); + await new Promise((resolveListen, rejectListen) => { + app.once("error", rejectListen); + app.listen(port, host, resolveListen); + }); + const bound = app.address(); + if (!bound || typeof bound === "string") throw new Error("console listener has no address"); + process.stderr.write(`listening on http://${host}:${bound.port}\n`); +} + +main().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : "console startup failed"}\n`); + process.exitCode = 1; +}); diff --git a/apps/console/src/server.ts b/apps/console/src/server.ts index 3cdc0fe77..327b0c988 100644 --- a/apps/console/src/server.ts +++ b/apps/console/src/server.ts @@ -26,8 +26,9 @@ export function serveConsole(adapters: ConsoleAdapters, origin: string): Server return createServer(async (message, reply) => { try { await send(reply, await app.fetch(await receive(message, origin))); - } catch { - await send(reply, Response.json({ error: { identifier: "ConsoleServerFailure" } }, { status: 500 })); + } catch (error) { + const unavailable = error instanceof Error && error.message === "ConsoleAdapterUnavailable"; + await send(reply, Response.json({ error: { identifier: unavailable ? "ConsoleAdapterUnavailable" : "ConsoleServerFailure" } }, { status: unavailable ? 503 : 500 })); } }); } diff --git a/apps/console/tsconfig.json b/apps/console/tsconfig.json new file mode 100644 index 000000000..bb9a739db --- /dev/null +++ b/apps/console/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "../gateway/src/index.ts"] +} From 0df029e2e166d0fc801bfecd81c04e4677002c95 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:50:29 +0800 Subject: [PATCH 04/14] fix(console): bind PKCE and Origin to the hosted session --- apps/console/dist/server.js | 37 ++++++++++++++++++--------- apps/console/src/entry.ts | 7 +++--- apps/console/src/index.ts | 32 +++++++++++++++++------ apps/console/src/server.ts | 4 +-- apps/console/test/console.test.ts | 42 ++++++++++++++++++++++++++++--- 5 files changed, 94 insertions(+), 28 deletions(-) diff --git a/apps/console/dist/server.js b/apps/console/dist/server.js index 63c3a70bf..810ef0a69 100644 --- a/apps/console/dist/server.js +++ b/apps/console/dist/server.js @@ -77,7 +77,7 @@ function registryFromEnv(raw) { import { createServer } from "node:http"; // src/index.ts -import { createHmac, createPublicKey, randomBytes, timingSafeEqual, verify } from "node:crypto"; +import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify } from "node:crypto"; function encoded(value) { return Buffer.from(JSON.stringify(value)).toString("base64url"); } @@ -163,29 +163,40 @@ function operatorFor(request, identity2) { function cognitoLogin(identity2) { if (!identity2.authorizeUrl || !identity2.clientId || !identity2.redirectUri) return refusal("ConsoleLoginUnconfigured", 503); const state = randomBytes(24).toString("base64url"); + const verifier = randomBytes(32).toString("base64url"); + const issued = Math.floor(Date.now() / 1e3).toString(); + const loginData = `${state}.${verifier}.${issued}`; + const signature = createHmac("sha256", identity2.sessionSecret).update(loginData).digest("base64url"); const location = new URL(identity2.authorizeUrl); location.searchParams.set("response_type", "code"); location.searchParams.set("client_id", identity2.clientId); location.searchParams.set("redirect_uri", identity2.redirectUri); location.searchParams.set("scope", "openid email profile"); location.searchParams.set("state", state); + location.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url")); + location.searchParams.set("code_challenge_method", "S256"); return new Response(null, { status: 302, headers: { Location: location.toString(), - "Set-Cookie": `console_login_state=${state}; HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=300`, + "Set-Cookie": `console_login_state=${loginData}.${signature}; HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=300`, "Cache-Control": "no-store" } }); } async function cognitoCallback(request, identity2) { - if (!identity2.tokenUrl || !identity2.clientId || !identity2.redirectUri || !identity2.issuer || !identity2.publicKey) return refusal("ConsoleLoginUnconfigured", 503); + if (!identity2.tokenUrl || !identity2.clientId || !identity2.redirectUri || !identity2.issuer || !identity2.publicKey && !identity2.keys) return refusal("ConsoleLoginUnconfigured", 503); const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); - const cookieState = request.headers.get("cookie")?.split(/;\s*/).find((part) => part.startsWith("console_login_state="))?.slice("console_login_state=".length); - if (!code || !state || !cookieState || !secureEqual(Buffer.from(state), Buffer.from(cookieState))) return refusal("ConsolePageForbidden"); + const loginCookie = request.headers.get("cookie")?.split(/;\s*/).find((part) => part.startsWith("console_login_state="))?.slice("console_login_state=".length); + const loginParts = loginCookie?.split("."); + if (!code || !state || !loginParts || loginParts.length !== 4) return refusal("ConsolePageForbidden"); + const [cookieState, verifier, issued, signature] = loginParts; + const expected = createHmac("sha256", identity2.sessionSecret).update(`${cookieState}.${verifier}.${issued}`).digest("base64url"); + const age = Math.floor(Date.now() / 1e3) - Number(issued); + if (!secureEqual(Buffer.from(state), Buffer.from(cookieState)) || !secureEqual(Buffer.from(signature), Buffer.from(expected)) || !Number.isInteger(age) || age < 0 || age > 300 || !/^[A-Za-z0-9_-]{43}$/.test(verifier)) return refusal("ConsolePageForbidden"); const exchange = await (identity2.fetcher ?? fetch)(identity2.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ grant_type: "authorization_code", client_id: identity2.clientId, redirect_uri: identity2.redirectUri, code }) + body: new URLSearchParams({ grant_type: "authorization_code", client_id: identity2.clientId, redirect_uri: identity2.redirectUri, code, code_verifier: verifier }) }); if (!exchange.ok) return refusal("ConsolePageForbidden"); const tokens = await exchange.json(); @@ -201,7 +212,8 @@ async function cognitoCallback(request, identity2) { return refusal("ConsolePageForbidden"); } if (!object(header) || header.alg !== "RS256" || !object(claims) || claims.iss !== identity2.issuer || claims.aud !== identity2.clientId || typeof claims.sub !== "string" || !claims.sub || !Number.isSafeInteger(claims.exp) || claims.exp <= Math.floor(Date.now() / 1e3) || claims.token_use !== "id") return refusal("ConsolePageForbidden"); - const key = typeof identity2.publicKey === "string" ? createPublicKey(identity2.publicKey) : identity2.publicKey; + const key = identity2.keys ? typeof header.kid === "string" ? identity2.keys.get(header.kid) : void 0 : typeof identity2.publicKey === "string" ? createPublicKey(identity2.publicKey) : identity2.publicKey; + if (!key) return refusal("ConsolePageForbidden"); if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"))) return refusal("ConsolePageForbidden"); const groups = Array.isArray(claims["cognito:groups"]) ? claims["cognito:groups"].filter((group) => typeof group === "string") : []; const lifetime = Math.min(claims.exp, Math.floor(Date.now() / 1e3) + 3600); @@ -254,7 +266,7 @@ function createConsole(adapters) { } if (!operator) return refusal("ConsolePageForbidden", 401); if (!operator.grants.has(grant)) return refusal("ConsolePageForbidden"); - if (adapters.identity.kind === "cognito" && request.method === "POST" && request.headers.get("origin") !== url.origin) { + if (request.method === "POST" && (grant === "admin" || adapters.identity.kind === "cognito") && request.headers.get("origin") !== url.origin) { return refusal("ConsolePageForbidden"); } if (request.method === "GET" && path === `/${grant}`) return page(grant); @@ -332,7 +344,7 @@ function serveConsole(adapters, origin) { const app = createConsole(adapters); return createServer(async (message, reply) => { try { - await send(reply, await app.fetch(await receive(message, origin))); + await send(reply, await app.fetch(await receive(message, typeof origin === "string" ? origin : origin()))); } catch (error) { const unavailable2 = error instanceof Error && error.message === "ConsoleAdapterUnavailable"; await send(reply, Response.json({ error: { identifier: unavailable2 ? "ConsoleAdapterUnavailable" : "ConsoleServerFailure" } }, { status: unavailable2 ? 503 : 500 })); @@ -364,7 +376,6 @@ async function keysAt(url) { async function identity() { if (process.env.CONTEXTFUL_IDENTITY === "cognito") { const keys = await keysAt(required("CONTEXTFUL_COGNITO_JWKS_URL")); - if (keys.size !== 1) throw new Error("Cognito key route needs one pinned key"); return { kind: "cognito", sessionSecret: required("CONTEXTFUL_COGNITO_SESSION_SECRET"), @@ -375,7 +386,7 @@ async function identity() { authorizeUrl: required("CONTEXTFUL_COGNITO_AUTHORIZE_URL"), tokenUrl: required("CONTEXTFUL_COGNITO_TOKEN_URL"), redirectUri: required("CONTEXTFUL_COGNITO_REDIRECT_URI"), - publicKey: [...keys.values()][0] + keys }; } return { @@ -418,6 +429,7 @@ async function main() { if (!module.createAdapters) throw new Error("console adapter module exports no createAdapters"); adapters = await module.createAdapters({ stores, env: process.env }); } + let origin = process.env.CONTEXTFUL_CONSOLE_ORIGIN ?? `http://${host}:${port}`; const app = serveConsole({ identity: await identity(), stores, @@ -425,13 +437,14 @@ async function main() { turn: adapters.turn, read: adapters.read ?? { list: async () => stores }, control: adapters.control - }, process.env.CONTEXTFUL_CONSOLE_ORIGIN ?? `http://${host}:${port}`); + }, () => origin); await new Promise((resolveListen, rejectListen) => { app.once("error", rejectListen); app.listen(port, host, resolveListen); }); const bound = app.address(); if (!bound || typeof bound === "string") throw new Error("console listener has no address"); + if (!process.env.CONTEXTFUL_CONSOLE_ORIGIN) origin = `http://${host}:${bound.port}`; process.stderr.write(`listening on http://${host}:${bound.port} `); } diff --git a/apps/console/src/entry.ts b/apps/console/src/entry.ts index 59b892446..0ee90294c 100644 --- a/apps/console/src/entry.ts +++ b/apps/console/src/entry.ts @@ -32,7 +32,6 @@ async function keysAt(url: string): Promise { if (process.env.CONTEXTFUL_IDENTITY === "cognito") { const keys = await keysAt(required("CONTEXTFUL_COGNITO_JWKS_URL")); - if (keys.size !== 1) throw new Error("Cognito key route needs one pinned key"); return { kind: "cognito", sessionSecret: required("CONTEXTFUL_COGNITO_SESSION_SECRET"), @@ -43,7 +42,7 @@ async function identity(): Promise { authorizeUrl: required("CONTEXTFUL_COGNITO_AUTHORIZE_URL"), tokenUrl: required("CONTEXTFUL_COGNITO_TOKEN_URL"), redirectUri: required("CONTEXTFUL_COGNITO_REDIRECT_URI"), - publicKey: [...keys.values()][0], + keys, }; } return { @@ -89,6 +88,7 @@ async function main(): Promise { if (!module.createAdapters) throw new Error("console adapter module exports no createAdapters"); adapters = await module.createAdapters({ stores, env: process.env }); } + let origin = process.env.CONTEXTFUL_CONSOLE_ORIGIN ?? `http://${host}:${port}`; const app = serveConsole({ identity: await identity(), stores, @@ -96,13 +96,14 @@ async function main(): Promise { turn: adapters.turn, read: adapters.read ?? { list: async () => stores }, control: adapters.control, - }, process.env.CONTEXTFUL_CONSOLE_ORIGIN ?? `http://${host}:${port}`); + }, () => origin); await new Promise((resolveListen, rejectListen) => { app.once("error", rejectListen); app.listen(port, host, resolveListen); }); const bound = app.address(); if (!bound || typeof bound === "string") throw new Error("console listener has no address"); + if (!process.env.CONTEXTFUL_CONSOLE_ORIGIN) origin = `http://${host}:${bound.port}`; process.stderr.write(`listening on http://${host}:${bound.port}\n`); } diff --git a/apps/console/src/index.ts b/apps/console/src/index.ts index 1a6408c7a..04d3486d1 100644 --- a/apps/console/src/index.ts +++ b/apps/console/src/index.ts @@ -1,4 +1,4 @@ -import { createHmac, createPublicKey, randomBytes, timingSafeEqual, verify, type KeyObject } from "node:crypto"; +import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify, type KeyObject } from "node:crypto"; export type Operator = { subject: string; grants: ReadonlySet<"query" | "admin"> }; export type Store = { id: string; label: string }; @@ -21,6 +21,7 @@ export type CognitoIdentity = { tokenUrl?: string; redirectUri?: string; publicKey?: KeyObject | string; + keys?: ReadonlyMap; fetcher?: typeof fetch; }; export type Identity = AccessIdentity | CognitoIdentity; @@ -124,30 +125,43 @@ function operatorFor(request: Request, identity: Identity): Operator | null { function cognitoLogin(identity: CognitoIdentity): Response { if (!identity.authorizeUrl || !identity.clientId || !identity.redirectUri) return refusal("ConsoleLoginUnconfigured", 503); const state = randomBytes(24).toString("base64url"); + const verifier = randomBytes(32).toString("base64url"); + const issued = Math.floor(Date.now() / 1000).toString(); + const loginData = `${state}.${verifier}.${issued}`; + const signature = createHmac("sha256", identity.sessionSecret).update(loginData).digest("base64url"); const location = new URL(identity.authorizeUrl); location.searchParams.set("response_type", "code"); location.searchParams.set("client_id", identity.clientId); location.searchParams.set("redirect_uri", identity.redirectUri); location.searchParams.set("scope", "openid email profile"); location.searchParams.set("state", state); + location.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url")); + location.searchParams.set("code_challenge_method", "S256"); return new Response(null, { status: 302, headers: { Location: location.toString(), - "Set-Cookie": `console_login_state=${state}; HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=300`, + "Set-Cookie": `console_login_state=${loginData}.${signature}; HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=300`, "Cache-Control": "no-store", } }); } async function cognitoCallback(request: Request, identity: CognitoIdentity): Promise { - if (!identity.tokenUrl || !identity.clientId || !identity.redirectUri || !identity.issuer || !identity.publicKey) return refusal("ConsoleLoginUnconfigured", 503); + if (!identity.tokenUrl || !identity.clientId || !identity.redirectUri || !identity.issuer || (!identity.publicKey && !identity.keys)) return refusal("ConsoleLoginUnconfigured", 503); const url = new URL(request.url); const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); - const cookieState = request.headers.get("cookie")?.split(/;\s*/).find((part) => part.startsWith("console_login_state="))?.slice("console_login_state=".length); - if (!code || !state || !cookieState || !secureEqual(Buffer.from(state), Buffer.from(cookieState))) return refusal("ConsolePageForbidden"); + const loginCookie = request.headers.get("cookie")?.split(/;\s*/).find((part) => part.startsWith("console_login_state="))?.slice("console_login_state=".length); + const loginParts = loginCookie?.split("."); + if (!code || !state || !loginParts || loginParts.length !== 4) return refusal("ConsolePageForbidden"); + const [cookieState, verifier, issued, signature] = loginParts; + const expected = createHmac("sha256", identity.sessionSecret).update(`${cookieState}.${verifier}.${issued}`).digest("base64url"); + const age = Math.floor(Date.now() / 1000) - Number(issued); + if (!secureEqual(Buffer.from(state), Buffer.from(cookieState)) || + !secureEqual(Buffer.from(signature), Buffer.from(expected)) || !Number.isInteger(age) || age < 0 || age > 300 || + !/^[A-Za-z0-9_-]{43}$/.test(verifier)) return refusal("ConsolePageForbidden"); const exchange = await (identity.fetcher ?? fetch)(identity.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ grant_type: "authorization_code", client_id: identity.clientId, redirect_uri: identity.redirectUri, code }), + body: new URLSearchParams({ grant_type: "authorization_code", client_id: identity.clientId, redirect_uri: identity.redirectUri, code, code_verifier: verifier }), }); if (!exchange.ok) return refusal("ConsolePageForbidden"); const tokens: unknown = await exchange.json(); @@ -160,7 +174,9 @@ async function cognitoCallback(request: Request, identity: CognitoIdentity): Pro if (!object(header) || header.alg !== "RS256" || !object(claims) || claims.iss !== identity.issuer || claims.aud !== identity.clientId || typeof claims.sub !== "string" || !claims.sub || !Number.isSafeInteger(claims.exp) || (claims.exp as number) <= Math.floor(Date.now() / 1000) || claims.token_use !== "id") return refusal("ConsolePageForbidden"); - const key = typeof identity.publicKey === "string" ? createPublicKey(identity.publicKey) : identity.publicKey; + const key = identity.keys ? (typeof header.kid === "string" ? identity.keys.get(header.kid) : undefined) : + typeof identity.publicKey === "string" ? createPublicKey(identity.publicKey) : identity.publicKey; + if (!key) return refusal("ConsolePageForbidden"); if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"))) return refusal("ConsolePageForbidden"); const groups = Array.isArray(claims["cognito:groups"]) ? claims["cognito:groups"].filter((group): group is string => typeof group === "string") : []; const lifetime = Math.min(claims.exp as number, Math.floor(Date.now() / 1000) + 3600); @@ -215,7 +231,7 @@ export function createConsole(adapters: ConsoleAdapters): { fetch: (request: Req try { operator = operatorFor(request, adapters.identity); } catch { operator = null; } if (!operator) return refusal("ConsolePageForbidden", 401); if (!operator.grants.has(grant)) return refusal("ConsolePageForbidden"); - if (adapters.identity.kind === "cognito" && request.method === "POST" && request.headers.get("origin") !== url.origin) { + if (request.method === "POST" && (grant === "admin" || adapters.identity.kind === "cognito") && request.headers.get("origin") !== url.origin) { return refusal("ConsolePageForbidden"); } if (request.method === "GET" && path === `/${grant}`) return page(grant); diff --git a/apps/console/src/server.ts b/apps/console/src/server.ts index 327b0c988..ab4ba0f65 100644 --- a/apps/console/src/server.ts +++ b/apps/console/src/server.ts @@ -21,11 +21,11 @@ async function send(reply: ServerResponse, response: Response): Promise { reply.end(Buffer.from(await response.arrayBuffer())); } -export function serveConsole(adapters: ConsoleAdapters, origin: string): Server { +export function serveConsole(adapters: ConsoleAdapters, origin: string | (() => string)): Server { const app = createConsole(adapters); return createServer(async (message, reply) => { try { - await send(reply, await app.fetch(await receive(message, origin))); + await send(reply, await app.fetch(await receive(message, typeof origin === "string" ? origin : origin()))); } catch (error) { const unavailable = error instanceof Error && error.message === "ConsoleAdapterUnavailable"; await send(reply, Response.json({ error: { identifier: unavailable ? "ConsoleAdapterUnavailable" : "ConsoleServerFailure" } }, { status: unavailable ? 503 : 500 })); diff --git a/apps/console/test/console.test.ts b/apps/console/test/console.test.ts index b92fbb7ca..4a66e9dd8 100644 --- a/apps/console/test/console.test.ts +++ b/apps/console/test/console.test.ts @@ -84,13 +84,17 @@ test("Admin exposes workflows and requires a server-held capability for edit and const { request, calls } = fixture(); const workflows = await request("/admin/api/workflows", access(adminAudience)); assert.equal((await workflows.json() as { pipelines: unknown[] }).pipelines.length, 1); - const edit = await request("/admin/api/edit", access(adminAudience), { method: "POST", body: "{}" }); + const origin = "https://console.example"; + const edit = await request("/admin/api/edit", { ...access(adminAudience), origin }, { method: "POST", body: "{}" }); assert.equal(edit.status, 200); - const apply = await request("/admin/api/apply", access(adminAudience), { method: "POST", body: "{}" }); + const apply = await request("/admin/api/apply", { ...access(adminAudience), origin }, { method: "POST", body: "{}" }); assert.equal(apply.status, 200); assert.deepEqual(calls, ["workflows", "edit:server-secret", "apply:server-secret"]); + assert.equal((await request("/admin/api/edit", access(adminAudience), { method: "POST", body: "{}" })).status, 403); + assert.equal((await request("/admin/api/edit", { ...access(adminAudience), origin: "https://other.example" }, { method: "POST", body: "{}" })).status, 403); + assert.deepEqual(calls, ["workflows", "edit:server-secret", "apply:server-secret"]); const withoutCapability = fixture(""); - const refused = await withoutCapability.request("/admin/api/apply", access(adminAudience), { method: "POST", body: "{}" }); + const refused = await withoutCapability.request("/admin/api/apply", { ...access(adminAudience), origin }, { method: "POST", body: "{}" }); assert.equal(refused.status, 403); assert.match(await refused.text(), /ConsoleAdminGrantMissing/); assert.deepEqual(withoutCapability.calls, []); @@ -126,6 +130,7 @@ test("Cognito hosted login exchanges a code before issuing a first-party session fetcher: async (_url, init) => { assert.equal(init?.method, "POST"); assert.match(String(init?.body), /code=accepted/); + assert.match(String(init?.body), /code_verifier=/); return Response.json({ id_token: jwt({ iss: "https://cognito.example/pool", aud: "console-client", sub: "operator-3", token_use: "id", "cognito:groups": ["console-admin"], exp: Math.floor(Date.now() / 1000) + 300 }) }); }, }, @@ -137,6 +142,8 @@ test("Cognito hosted login exchanges a code before issuing a first-party session const location = new URL(login.headers.get("location")!); const state = location.searchParams.get("state"); assert.ok(state); + assert.equal(location.searchParams.get("code_challenge_method"), "S256"); + assert.match(location.searchParams.get("code_challenge") ?? "", /^[A-Za-z0-9_-]{43}$/); const stateCookie = login.headers.get("set-cookie")!.split(";")[0]; const callback = await app.fetch(new Request(`https://console.example/auth/callback?code=accepted&state=${state}`, { headers: { cookie: stateCookie } })); assert.equal(callback.status, 302); @@ -146,6 +153,35 @@ test("Cognito hosted login exchanges a code before issuing a first-party session assert.equal((await app.fetch(new Request("https://console.example/query", { headers: { cookie: sessionCookie } }))).status, 403); const badState = await app.fetch(new Request("https://console.example/auth/callback?code=accepted&state=wrong", { headers: { cookie: stateCookie } })); assert.equal(badState.status, 403); + const missingVerifier = await app.fetch(new Request(`https://console.example/auth/callback?code=accepted&state=${state}`, { headers: { cookie: `console_login_state=${state}` } })); + assert.equal(missingVerifier.status, 403); + const [cookieName, cookieValue] = stateCookie.split("="); + const tampered = cookieValue.split("."); + tampered[1] = `${tampered[1].slice(0, -1)}${tampered[1].endsWith("A") ? "B" : "A"}`; + const wrongVerifier = await app.fetch(new Request(`https://console.example/auth/callback?code=accepted&state=${state}`, { headers: { cookie: `${cookieName}=${tampered.join(".")}` } })); + assert.equal(wrongVerifier.status, 403); +}); + +test("Cognito Admin POST uses the listener's bound port for its Origin", async () => { + let edits = 0; + const app = serveConsole({ + identity: { kind: "cognito", sessionSecret: "session-secret", queryGroup: "query", adminGroup: "admin" }, + adminCapability: "server-secret", + stores: [], turn: async () => ({ answer: "", sources: [], widgets: [] }), read: { list: async () => [] }, + control: { workflows: async () => ({}), record: async () => ({}), edit: async () => { edits++; return { version: 1 }; }, apply: async () => ({}) }, + }, () => origin); + await new Promise((resolve) => app.listen(0, "127.0.0.1", resolve)); + const address = app.address(); + assert.ok(address && typeof address !== "string"); + const origin = `http://127.0.0.1:${address.port}`; + const session = issueCognitoSession({ subject: "operator", groups: ["admin"], expiresAt: Math.floor(Date.now() / 1000) + 60 }, "session-secret"); + try { + const response = await fetch(`${origin}/admin/api/edit`, { + method: "POST", headers: { cookie: `console_session=${session}`, origin, "content-type": "application/json" }, body: "{}", + }); + assert.equal(response.status, 200); + assert.equal(edits, 1); + } finally { app.close(); } }); test("the Node HTTP listener verifies a page request before serving content", async () => { From 76b708423bb9bd378702b4f7922fe576c204b912 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:55:11 +0800 Subject: [PATCH 05/14] test(console): reject oversized HTTP request bodies --- apps/console/test/console.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/console/test/console.test.ts b/apps/console/test/console.test.ts index 4a66e9dd8..015dee6f1 100644 --- a/apps/console/test/console.test.ts +++ b/apps/console/test/console.test.ts @@ -212,6 +212,28 @@ test("the Node HTTP listener verifies a page request before serving content", as } }); +test("the Node HTTP listener rejects an oversized body before dispatch", async () => { + let turns = 0; + const server = serveConsole({ + identity: { kind: "access", issuer, queryAudience, adminAudience, publicKey }, + stores: [{ id: "field-notes", label: "Field notes" }], + turn: async () => { turns++; return { answer: "", sources: [], widgets: [] }; }, + read: { list: async () => [] }, + control: { workflows: async () => ({}), record: async () => ({}), edit: async () => ({}), apply: async () => ({}) }, + }, "http://127.0.0.1"); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const response = await fetch(`http://127.0.0.1:${address.port}/query/api/ask`, { + method: "POST", headers: { "content-type": "application/json" }, body: Buffer.alloc(1_048_577, "x"), + }); + assert.equal(response.status, 413); + assert.equal((await response.json() as { error: { identifier: string } }).error.identifier, "ConsoleBodyTooLarge"); + assert.equal(turns, 0); + } finally { server.close(); } +}); + test("Admin pack listing reaches only the injected listing adapter", async () => { const calls: string[] = []; const app = createConsole({ From 0e57b00c00706fee4abcc320e680c6afdfa72e71 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:55:35 +0800 Subject: [PATCH 06/14] fix(console): cap incoming HTTP bodies at one MiB --- apps/console/src/server.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/console/src/server.ts b/apps/console/src/server.ts index ab4ba0f65..c5aa76f6c 100644 --- a/apps/console/src/server.ts +++ b/apps/console/src/server.ts @@ -2,10 +2,17 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import type { ConsoleAdapters } from "./index.ts"; import { createConsole } from "./index.ts"; +const maxBodyBytes = 1_048_576; + async function receive(message: IncomingMessage, origin: string): Promise { const method = message.method ?? "GET"; const chunks: Buffer[] = []; - for await (const chunk of message) chunks.push(Buffer.from(chunk)); + let size = 0; + for await (const chunk of message) { + size += chunk.length; + if (size > maxBodyBytes) throw new Error("ConsoleBodyTooLarge"); + chunks.push(Buffer.from(chunk)); + } const body = method === "GET" || method === "HEAD" ? undefined : Buffer.concat(chunks); const headers = new Headers(); for (const [key, value] of Object.entries(message.headers)) { @@ -27,6 +34,10 @@ export function serveConsole(adapters: ConsoleAdapters, origin: string | (() => try { await send(reply, await app.fetch(await receive(message, typeof origin === "string" ? origin : origin()))); } catch (error) { + if (error instanceof Error && error.message === "ConsoleBodyTooLarge") { + await send(reply, Response.json({ error: { identifier: "ConsoleBodyTooLarge" } }, { status: 413 })); + return; + } const unavailable = error instanceof Error && error.message === "ConsoleAdapterUnavailable"; await send(reply, Response.json({ error: { identifier: unavailable ? "ConsoleAdapterUnavailable" : "ConsoleServerFailure" } }, { status: unavailable ? 503 : 500 })); } From 8ac2cbd87657032d5b383b1fed95c1656ab88a22 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:56:01 +0800 Subject: [PATCH 07/14] build(console): refresh server bundle with request cap --- apps/console/dist/server.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/console/dist/server.js b/apps/console/dist/server.js index 810ef0a69..8fc15d7f7 100644 --- a/apps/console/dist/server.js +++ b/apps/console/dist/server.js @@ -323,10 +323,16 @@ const canvas=document.getElementById('canvas'),record=document.getElementById('r `; // src/server.ts +var maxBodyBytes = 1048576; async function receive(message, origin) { const method = message.method ?? "GET"; const chunks = []; - for await (const chunk of message) chunks.push(Buffer.from(chunk)); + let size = 0; + for await (const chunk of message) { + size += chunk.length; + if (size > maxBodyBytes) throw new Error("ConsoleBodyTooLarge"); + chunks.push(Buffer.from(chunk)); + } const body2 = method === "GET" || method === "HEAD" ? void 0 : Buffer.concat(chunks); const headers = new Headers(); for (const [key, value] of Object.entries(message.headers)) { @@ -346,6 +352,10 @@ function serveConsole(adapters, origin) { try { await send(reply, await app.fetch(await receive(message, typeof origin === "string" ? origin : origin()))); } catch (error) { + if (error instanceof Error && error.message === "ConsoleBodyTooLarge") { + await send(reply, Response.json({ error: { identifier: "ConsoleBodyTooLarge" } }, { status: 413 })); + return; + } const unavailable2 = error instanceof Error && error.message === "ConsoleAdapterUnavailable"; await send(reply, Response.json({ error: { identifier: unavailable2 ? "ConsoleAdapterUnavailable" : "ConsoleServerFailure" } }, { status: unavailable2 ? 503 : 500 })); } From 5164c4bca8f8269af3b955b4265c1d297d48cfe6 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:58:59 +0800 Subject: [PATCH 08/14] test(console): reject Access Query POST without same-origin Origin --- apps/console/test/console.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/console/test/console.test.ts b/apps/console/test/console.test.ts index 015dee6f1..514c30c9b 100644 --- a/apps/console/test/console.test.ts +++ b/apps/console/test/console.test.ts @@ -74,12 +74,21 @@ test("Query keeps one composer and transcript and delegates a sourced answer", a assert.match(page, /id="composer"/); assert.match(page, /id="transcript"/); assert.match(page, /id="widgets"/); - const answer = await request("/query/api/ask", access(queryAudience), { method: "POST", body: JSON.stringify({ store: "field-notes", question: "What arrived?" }) }); + const answer = await request("/query/api/ask", { ...access(queryAudience), origin: "https://console.example" }, { method: "POST", body: JSON.stringify({ store: "field-notes", question: "What arrived?" }) }); assert.equal(answer.status, 200); assert.equal((await answer.json() as { sources: unknown[] }).sources.length, 1); assert.deepEqual(calls, ["turn:operator-1:field-notes:What arrived?"]); }); +test("Access Query rejects cross-origin and originless POST before a turn", async () => { + const { request, calls } = fixture(); + const body = JSON.stringify({ store: "field-notes", question: "What arrived?" }); + for (const headers of [access(queryAudience), { ...access(queryAudience), origin: "https://other.example" }]) { + assert.equal((await request("/query/api/ask", headers, { method: "POST", body })).status, 403); + } + assert.deepEqual(calls, []); +}); + test("Admin exposes workflows and requires a server-held capability for edit and apply", async () => { const { request, calls } = fixture(); const workflows = await request("/admin/api/workflows", access(adminAudience)); From 4bde6e0821ffb8212aa28e36a76b6e02ad40d7a3 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:59:36 +0800 Subject: [PATCH 09/14] fix(console): require same-origin Origin for Access Query writes --- apps/console/dist/server.js | 2 +- apps/console/src/index.ts | 2 +- crates/acceptance/tests/integration/m12.rs | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/console/dist/server.js b/apps/console/dist/server.js index 8fc15d7f7..221f1910f 100644 --- a/apps/console/dist/server.js +++ b/apps/console/dist/server.js @@ -266,7 +266,7 @@ function createConsole(adapters) { } if (!operator) return refusal("ConsolePageForbidden", 401); if (!operator.grants.has(grant)) return refusal("ConsolePageForbidden"); - if (request.method === "POST" && (grant === "admin" || adapters.identity.kind === "cognito") && request.headers.get("origin") !== url.origin) { + if (request.method === "POST" && request.headers.get("origin") !== url.origin) { return refusal("ConsolePageForbidden"); } if (request.method === "GET" && path === `/${grant}`) return page(grant); diff --git a/apps/console/src/index.ts b/apps/console/src/index.ts index 04d3486d1..4b1ece282 100644 --- a/apps/console/src/index.ts +++ b/apps/console/src/index.ts @@ -231,7 +231,7 @@ export function createConsole(adapters: ConsoleAdapters): { fetch: (request: Req try { operator = operatorFor(request, adapters.identity); } catch { operator = null; } if (!operator) return refusal("ConsolePageForbidden", 401); if (!operator.grants.has(grant)) return refusal("ConsolePageForbidden"); - if (request.method === "POST" && (grant === "admin" || adapters.identity.kind === "cognito") && request.headers.get("origin") !== url.origin) { + if (request.method === "POST" && request.headers.get("origin") !== url.origin) { return refusal("ConsolePageForbidden"); } if (request.method === "GET" && path === `/${grant}`) return page(grant); diff --git a/crates/acceptance/tests/integration/m12.rs b/crates/acceptance/tests/integration/m12.rs index 24924593b..985a42875 100644 --- a/crates/acceptance/tests/integration/m12.rs +++ b/crates/acceptance/tests/integration/m12.rs @@ -65,9 +65,10 @@ fn request(address: &str, method: &str, path: &str, token: Option<&str>, body: O let mut stream = TcpStream::connect(address).unwrap(); let data = body.map(Value::to_string).unwrap_or_default(); let auth = token.map(|t| format!("Cf-Access-Jwt-Assertion: {t}\r\n")).unwrap_or_default(); + let origin = if method == "POST" { format!("Origin: http://{address}\r\n") } else { String::new() }; write!( stream, - "{method} {path} HTTP/1.1\r\nHost: {address}\r\n{auth}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{data}", + "{method} {path} HTTP/1.1\r\nHost: {address}\r\n{auth}{origin}Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{data}", data.len() ) .unwrap(); From 8cce2d915585d3c84e1918d494b04fd30e842c77 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 14:52:56 +0800 Subject: [PATCH 10/14] test(console): bound streamed bodies and validate Admin store --- apps/console/test/console.test.ts | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/console/test/console.test.ts b/apps/console/test/console.test.ts index 514c30c9b..26da13e41 100644 --- a/apps/console/test/console.test.ts +++ b/apps/console/test/console.test.ts @@ -243,6 +243,42 @@ test("the Node HTTP listener rejects an oversized body before dispatch", async ( } finally { server.close(); } }); +test("Query counts UTF-8 bytes without Content-Length before parsing", async () => { + const { request, calls } = fixture(); + const body = JSON.stringify({ store: "field-notes", question: "é".repeat(524_289) }); + const response = await request("/query/api/ask", { ...access(queryAudience), origin: "https://console.example" }, { method: "POST", body }); + assert.equal(response.status, 413); + assert.equal((await response.json() as { error: { identifier: string } }).error.identifier, "ConsoleBodyTooLarge"); + assert.deepEqual(calls, []); +}); + +test("Query stops reading a chunked body when its byte cap is crossed", async () => { + const { request, calls } = fixture(); + let pulls = 0; + const stream = new ReadableStream({ + pull(controller) { + pulls++; + controller.enqueue(Buffer.alloc(524_289, 120)); + if (pulls === 4) controller.close(); + }, + }, { highWaterMark: 0 }); + const response = await request("/query/api/ask", { ...access(queryAudience), origin: "https://console.example" }, + { method: "POST", body: stream, duplex: "half" } as RequestInit); + assert.equal(response.status, 413); + assert.ok(pulls < 4, `read ${pulls} chunks past the cap`); + assert.deepEqual(calls, []); +}); + +test("Admin rejects an unknown store before workflow and record adapters", async () => { + const { request, calls } = fixture(); + for (const path of ["/admin/api/workflows", "/admin/api/record"]) { + const response = await request(`${path}?store=missing`, access(adminAudience)); + assert.equal(response.status, 400); + assert.equal((await response.json() as { error: { identifier: string } }).error.identifier, "ConsoleRequestMalformed"); + } + assert.deepEqual(calls, []); +}); + test("Admin pack listing reaches only the injected listing adapter", async () => { const calls: string[] = []; const app = createConsole({ From ebdd59daa97489cbe229c79cf37423757f1d077b Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 14:54:23 +0800 Subject: [PATCH 11/14] fix(console): enforce byte cap while reading requests --- apps/console/dist/server.js | 38 +++++++++++++++++++++++++++++-------- apps/console/src/index.ts | 35 ++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/apps/console/dist/server.js b/apps/console/dist/server.js index 221f1910f..749033e5d 100644 --- a/apps/console/dist/server.js +++ b/apps/console/dist/server.js @@ -232,10 +232,31 @@ function json(value) { return Response.json(value, { headers: { "Cache-Control": "no-store" } }); } async function body(request) { - if (Number(request.headers.get("content-length") ?? 0) > 1048576) throw new Error("body too large"); - const text = await request.text(); - if (text.length > 1048576) throw new Error("body too large"); - return JSON.parse(text); + const limit = 1048576; + if (Number(request.headers.get("content-length") ?? 0) > limit) throw new Error("ConsoleBodyTooLarge"); + const chunks = []; + let size = 0; + const reader = request.body?.getReader(); + if (!reader) return JSON.parse(""); + try { + for (; ; ) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + void reader.cancel().catch(() => { + }); + throw new Error("ConsoleBodyTooLarge"); + } + chunks.push(Buffer.from(value)); + } + } finally { + reader.releaseLock(); + } + return JSON.parse(Buffer.concat(chunks, size).toString("utf8")); +} +function bodyFailure(error) { + return error instanceof Error && error.message === "ConsoleBodyTooLarge" ? refusal("ConsoleBodyTooLarge", 413) : refusal("ConsoleRequestMalformed", 400); } function page(name) { return new Response(name === "query" ? queryPage : adminPage, { @@ -276,14 +297,15 @@ function createConsole(adapters) { let input; try { input = await body(request); - } catch { - return refusal("ConsoleRequestMalformed", 400); + } catch (error) { + return bodyFailure(error); } if (!object(input) || typeof input.store !== "string" || typeof input.question !== "string" || !input.question.trim() || !adapters.stores.some((store) => store.id === input.store)) return refusal("ConsoleRequestMalformed", 400); return json(await adapters.turn({ operator, store: input.store, question: input.question })); } } else { const store = url.searchParams.get("store"); + if (store !== null && !adapters.stores.some((entry) => entry.id === store)) return refusal("ConsoleRequestMalformed", 400); if (request.method === "GET" && path === "/admin/api/workflows") return json(await adapters.control.workflows(operator, store)); if (request.method === "GET" && path === "/admin/api/record") return json(await adapters.control.record(operator, store)); if (request.method === "GET" && path === "/admin/api/packs") { @@ -298,8 +320,8 @@ function createConsole(adapters) { let document; try { document = await body(request); - } catch { - return refusal("ConsoleRequestMalformed", 400); + } catch (error) { + return bodyFailure(error); } return json(path.endsWith("/edit") ? await adapters.control.edit(document, adapters.adminCapability, operator) : await adapters.control.apply(document, adapters.adminCapability, operator)); } diff --git a/apps/console/src/index.ts b/apps/console/src/index.ts index 4b1ece282..beab27127 100644 --- a/apps/console/src/index.ts +++ b/apps/console/src/index.ts @@ -198,10 +198,32 @@ function json(value: unknown): Response { } async function body(request: Request): Promise { - if (Number(request.headers.get("content-length") ?? 0) > 1_048_576) throw new Error("body too large"); - const text = await request.text(); - if (text.length > 1_048_576) throw new Error("body too large"); - return JSON.parse(text); + const limit = 1_048_576; + if (Number(request.headers.get("content-length") ?? 0) > limit) throw new Error("ConsoleBodyTooLarge"); + const chunks: Buffer[] = []; + let size = 0; + const reader = request.body?.getReader(); + if (!reader) return JSON.parse(""); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + void reader.cancel().catch(() => {}); + throw new Error("ConsoleBodyTooLarge"); + } + chunks.push(Buffer.from(value)); + } + } finally { + reader.releaseLock(); + } + return JSON.parse(Buffer.concat(chunks, size).toString("utf8")); +} + +function bodyFailure(error: unknown): Response { + return error instanceof Error && error.message === "ConsoleBodyTooLarge" ? + refusal("ConsoleBodyTooLarge", 413) : refusal("ConsoleRequestMalformed", 400); } function page(name: "query" | "admin"): Response { @@ -239,13 +261,14 @@ export function createConsole(adapters: ConsoleAdapters): { fetch: (request: Req if (request.method === "GET" && path === "/query/api/stores") return json(await adapters.read.list(operator)); if (request.method === "POST" && path === "/query/api/ask") { let input: unknown; - try { input = await body(request); } catch { return refusal("ConsoleRequestMalformed", 400); } + try { input = await body(request); } catch (error) { return bodyFailure(error); } if (!object(input) || typeof input.store !== "string" || typeof input.question !== "string" || !input.question.trim() || !adapters.stores.some((store) => store.id === input.store)) return refusal("ConsoleRequestMalformed", 400); return json(await adapters.turn({ operator, store: input.store, question: input.question })); } } else { const store = url.searchParams.get("store"); + if (store !== null && !adapters.stores.some((entry) => entry.id === store)) return refusal("ConsoleRequestMalformed", 400); if (request.method === "GET" && path === "/admin/api/workflows") return json(await adapters.control.workflows(operator, store)); if (request.method === "GET" && path === "/admin/api/record") return json(await adapters.control.record(operator, store)); if (request.method === "GET" && path === "/admin/api/packs") { @@ -258,7 +281,7 @@ export function createConsole(adapters: ConsoleAdapters): { fetch: (request: Req const origin = request.headers.get("origin"); if (origin && origin !== url.origin) return refusal("ConsolePageForbidden"); let document: unknown; - try { document = await body(request); } catch { return refusal("ConsoleRequestMalformed", 400); } + try { document = await body(request); } catch (error) { return bodyFailure(error); } return json(path.endsWith("/edit") ? await adapters.control.edit(document, adapters.adminCapability, operator) : await adapters.control.apply(document, adapters.adminCapability, operator)); From 02040df2ffec759a72c1b7e81b83492095b2fa2e Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 17:28:56 +0800 Subject: [PATCH 12/14] chore(console): ignore generated dist output --- .gitignore | 1 + apps/console/dist/server.js | 487 ------------------------------------ 2 files changed, 1 insertion(+), 487 deletions(-) delete mode 100644 apps/console/dist/server.js diff --git a/.gitignore b/.gitignore index 96cc2c66c..2df1c86d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /target node_modules/ +dist/ .DS_Store *.wasm .tmp/ diff --git a/apps/console/dist/server.js b/apps/console/dist/server.js deleted file mode 100644 index 749033e5d..000000000 --- a/apps/console/dist/server.js +++ /dev/null @@ -1,487 +0,0 @@ -// src/entry.ts -import { createPublicKey as createPublicKey2 } from "node:crypto"; -import { pathToFileURL } from "node:url"; -import { isAbsolute, resolve } from "node:path"; - -// ../gateway/src/index.ts -var RegistryError = class extends Error { - identifier; - constructor(identifier, message) { - super(`${identifier}: ${message}`); - this.identifier = identifier; - this.name = identifier; - } -}; -var RESERVED_IDS = /* @__PURE__ */ new Set(["admin", "query"]); -var ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; -function names(id) { - const bindingName = id.replaceAll("-", "_").toUpperCase(); - return { bindingName, credentialName: `${bindingName}_QUERY_TOKEN` }; -} -function malformed(index) { - return { identifier: "StoreEntryMalformed", entry: index }; -} -function decodeEntry(value, index) { - if (!value || typeof value !== "object" || Array.isArray(value)) throw malformed(index); - const raw = value; - if (typeof raw.id !== "string" || !ID.test(raw.id)) throw malformed(index); - if (RESERVED_IDS.has(raw.id)) throw new RegistryError("StoreIdReserved", raw.id); - for (const key of ["credential", "credentialName", "binding", "bindingName"]) { - if (key in raw) throw new RegistryError("StoreNameAuthored", key); - } - if (typeof raw.endpoint !== "string") throw malformed(index); - let endpoint; - try { - endpoint = new URL(raw.endpoint); - } catch { - throw malformed(index); - } - if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") throw malformed(index); - for (const key of ["label", "auth", "exchangeRoute", "packPrefix"]) { - if (key in raw && typeof raw[key] !== "string") throw malformed(index); - } - const { credentialName, bindingName } = names(raw.id); - return { - id: raw.id, - label: typeof raw.label === "string" ? raw.label : raw.id, - endpoint: endpoint.toString().replace(/\/$/, ""), - auth: typeof raw.auth === "string" ? raw.auth : void 0, - exchangeRoute: typeof raw.exchangeRoute === "string" ? raw.exchangeRoute : void 0, - packPrefix: typeof raw.packPrefix === "string" ? raw.packPrefix : void 0, - credentialName, - bindingName - }; -} -function registryFromEnv(raw) { - let parsed; - try { - parsed = JSON.parse(raw ?? "[]"); - } catch (error) { - throw new RegistryError("StoreRegistryUnreadable", error instanceof Error ? error.message : "invalid JSON"); - } - if (!Array.isArray(parsed)) throw new RegistryError("StoreRegistryUnreadable", "expected an array"); - const entries = []; - const problems = []; - for (const [index, value] of parsed.entries()) { - try { - entries.push(decodeEntry(value, index)); - } catch (error) { - if (error instanceof RegistryError && (error.identifier === "StoreIdReserved" || error.identifier === "StoreNameAuthored")) throw error; - problems.push(malformed(index)); - } - } - return { entries, problems }; -} - -// src/server.ts -import { createServer } from "node:http"; - -// src/index.ts -import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify } from "node:crypto"; -function encoded(value) { - return Buffer.from(JSON.stringify(value)).toString("base64url"); -} -function decode(value) { - return JSON.parse(Buffer.from(value, "base64url").toString("utf8")); -} -function object(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} -function secureEqual(left, right) { - return left.length === right.length && timingSafeEqual(left, right); -} -function issueCognitoSession(session, secret) { - if (!secret || !session.subject || !Number.isSafeInteger(session.expiresAt) || session.expiresAt <= Math.floor(Date.now() / 1e3)) { - throw new Error("CognitoSessionInvalid"); - } - const body2 = encoded(session); - const signature = createHmac("sha256", secret).update(body2).digest("base64url"); - return `${body2}.${signature}`; -} -function verifyCognitoSession(cookie, identity2) { - const token = cookie.split("; ").find((part) => part.startsWith("console_session="))?.slice("console_session=".length); - if (!token) return null; - const parts = token.split("."); - if (parts.length !== 2) return null; - const expected = createHmac("sha256", identity2.sessionSecret).update(parts[0]).digest(); - let actual; - try { - actual = Buffer.from(parts[1], "base64url"); - } catch { - return null; - } - if (!secureEqual(expected, actual)) return null; - let session; - try { - session = decode(parts[0]); - } catch { - return null; - } - if (!object(session) || typeof session.subject !== "string" || !session.subject || !Number.isSafeInteger(session.expiresAt) || session.expiresAt <= Math.floor(Date.now() / 1e3) || !Array.isArray(session.groups) || !session.groups.every((group) => typeof group === "string")) return null; - const grants = /* @__PURE__ */ new Set(); - if (session.groups.includes(identity2.queryGroup)) grants.add("query"); - if (session.groups.includes(identity2.adminGroup)) grants.add("admin"); - return { subject: session.subject, grants }; -} -function verifyAccess(assertion, identity2) { - const parts = assertion.split("."); - if (parts.length !== 3) return null; - let header; - let claims; - try { - header = decode(parts[0]); - claims = decode(parts[1]); - } catch { - return null; - } - if (!object(header) || header.alg !== "RS256" || !object(claims)) return null; - if (claims.iss !== identity2.issuer || typeof claims.sub !== "string" || !claims.sub || !Number.isSafeInteger(claims.exp) || claims.exp <= Math.floor(Date.now() / 1e3)) return null; - if (typeof claims.nbf === "number" && claims.nbf > Math.floor(Date.now() / 1e3)) return null; - const audience = typeof claims.aud === "string" ? [claims.aud] : Array.isArray(claims.aud) ? claims.aud : []; - const grants = /* @__PURE__ */ new Set(); - if (audience.includes(identity2.queryAudience)) grants.add("query"); - if (audience.includes(identity2.adminAudience)) grants.add("admin"); - if (grants.size === 0) return null; - let signature; - try { - signature = Buffer.from(parts[2], "base64url"); - } catch { - return null; - } - const key = identity2.keys ? typeof header.kid === "string" ? identity2.keys.get(header.kid) : void 0 : typeof identity2.publicKey === "string" ? createPublicKey(identity2.publicKey) : identity2.publicKey; - if (!key) return null; - if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, signature)) return null; - return { subject: claims.sub, grants }; -} -function operatorFor(request, identity2) { - if (identity2.kind === "access") { - const assertion = request.headers.get("cf-access-jwt-assertion"); - return assertion ? verifyAccess(assertion, identity2) : null; - } - return verifyCognitoSession(request.headers.get("cookie") ?? "", identity2); -} -function cognitoLogin(identity2) { - if (!identity2.authorizeUrl || !identity2.clientId || !identity2.redirectUri) return refusal("ConsoleLoginUnconfigured", 503); - const state = randomBytes(24).toString("base64url"); - const verifier = randomBytes(32).toString("base64url"); - const issued = Math.floor(Date.now() / 1e3).toString(); - const loginData = `${state}.${verifier}.${issued}`; - const signature = createHmac("sha256", identity2.sessionSecret).update(loginData).digest("base64url"); - const location = new URL(identity2.authorizeUrl); - location.searchParams.set("response_type", "code"); - location.searchParams.set("client_id", identity2.clientId); - location.searchParams.set("redirect_uri", identity2.redirectUri); - location.searchParams.set("scope", "openid email profile"); - location.searchParams.set("state", state); - location.searchParams.set("code_challenge", createHash("sha256").update(verifier).digest("base64url")); - location.searchParams.set("code_challenge_method", "S256"); - return new Response(null, { status: 302, headers: { - Location: location.toString(), - "Set-Cookie": `console_login_state=${loginData}.${signature}; HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=300`, - "Cache-Control": "no-store" - } }); -} -async function cognitoCallback(request, identity2) { - if (!identity2.tokenUrl || !identity2.clientId || !identity2.redirectUri || !identity2.issuer || !identity2.publicKey && !identity2.keys) return refusal("ConsoleLoginUnconfigured", 503); - const url = new URL(request.url); - const code = url.searchParams.get("code"); - const state = url.searchParams.get("state"); - const loginCookie = request.headers.get("cookie")?.split(/;\s*/).find((part) => part.startsWith("console_login_state="))?.slice("console_login_state=".length); - const loginParts = loginCookie?.split("."); - if (!code || !state || !loginParts || loginParts.length !== 4) return refusal("ConsolePageForbidden"); - const [cookieState, verifier, issued, signature] = loginParts; - const expected = createHmac("sha256", identity2.sessionSecret).update(`${cookieState}.${verifier}.${issued}`).digest("base64url"); - const age = Math.floor(Date.now() / 1e3) - Number(issued); - if (!secureEqual(Buffer.from(state), Buffer.from(cookieState)) || !secureEqual(Buffer.from(signature), Buffer.from(expected)) || !Number.isInteger(age) || age < 0 || age > 300 || !/^[A-Za-z0-9_-]{43}$/.test(verifier)) return refusal("ConsolePageForbidden"); - const exchange = await (identity2.fetcher ?? fetch)(identity2.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ grant_type: "authorization_code", client_id: identity2.clientId, redirect_uri: identity2.redirectUri, code, code_verifier: verifier }) - }); - if (!exchange.ok) return refusal("ConsolePageForbidden"); - const tokens = await exchange.json(); - if (!object(tokens) || typeof tokens.id_token !== "string") return refusal("ConsolePageForbidden"); - const parts = tokens.id_token.split("."); - if (parts.length !== 3) return refusal("ConsolePageForbidden"); - let header; - let claims; - try { - header = decode(parts[0]); - claims = decode(parts[1]); - } catch { - return refusal("ConsolePageForbidden"); - } - if (!object(header) || header.alg !== "RS256" || !object(claims) || claims.iss !== identity2.issuer || claims.aud !== identity2.clientId || typeof claims.sub !== "string" || !claims.sub || !Number.isSafeInteger(claims.exp) || claims.exp <= Math.floor(Date.now() / 1e3) || claims.token_use !== "id") return refusal("ConsolePageForbidden"); - const key = identity2.keys ? typeof header.kid === "string" ? identity2.keys.get(header.kid) : void 0 : typeof identity2.publicKey === "string" ? createPublicKey(identity2.publicKey) : identity2.publicKey; - if (!key) return refusal("ConsolePageForbidden"); - if (!verify("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(parts[2], "base64url"))) return refusal("ConsolePageForbidden"); - const groups = Array.isArray(claims["cognito:groups"]) ? claims["cognito:groups"].filter((group) => typeof group === "string") : []; - const lifetime = Math.min(claims.exp, Math.floor(Date.now() / 1e3) + 3600); - const session = issueCognitoSession({ subject: claims.sub, groups, expiresAt: lifetime }, identity2.sessionSecret); - const destination = groups.includes(identity2.adminGroup) ? "/admin" : "/query"; - return new Response(null, { status: 302, headers: { - Location: destination, - "Set-Cookie": `console_session=${session}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${lifetime - Math.floor(Date.now() / 1e3)}`, - "Cache-Control": "no-store" - } }); -} -function refusal(identifier, status = 403) { - return Response.json({ error: { identifier } }, { status, headers: { "Cache-Control": "no-store" } }); -} -function json(value) { - return Response.json(value, { headers: { "Cache-Control": "no-store" } }); -} -async function body(request) { - const limit = 1048576; - if (Number(request.headers.get("content-length") ?? 0) > limit) throw new Error("ConsoleBodyTooLarge"); - const chunks = []; - let size = 0; - const reader = request.body?.getReader(); - if (!reader) return JSON.parse(""); - try { - for (; ; ) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > limit) { - void reader.cancel().catch(() => { - }); - throw new Error("ConsoleBodyTooLarge"); - } - chunks.push(Buffer.from(value)); - } - } finally { - reader.releaseLock(); - } - return JSON.parse(Buffer.concat(chunks, size).toString("utf8")); -} -function bodyFailure(error) { - return error instanceof Error && error.message === "ConsoleBodyTooLarge" ? refusal("ConsoleBodyTooLarge", 413) : refusal("ConsoleRequestMalformed", 400); -} -function page(name) { - return new Response(name === "query" ? queryPage : adminPage, { - headers: { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "no-store", - "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", - "X-Content-Type-Options": "nosniff" - } - }); -} -function createConsole(adapters) { - return { - async fetch(request) { - const url = new URL(request.url); - const path = url.pathname; - if (adapters.identity.kind === "cognito") { - if (path === "/auth/login" && request.method === "GET") return cognitoLogin(adapters.identity); - if (path === "/auth/callback" && request.method === "GET") return cognitoCallback(request, adapters.identity); - } - const grant = path === "/query" || path.startsWith("/query/api/") ? "query" : path === "/admin" || path.startsWith("/admin/api/") ? "admin" : null; - if (!grant) return refusal("ConsoleRouteNotFound", 404); - let operator; - try { - operator = operatorFor(request, adapters.identity); - } catch { - operator = null; - } - if (!operator) return refusal("ConsolePageForbidden", 401); - if (!operator.grants.has(grant)) return refusal("ConsolePageForbidden"); - if (request.method === "POST" && request.headers.get("origin") !== url.origin) { - return refusal("ConsolePageForbidden"); - } - if (request.method === "GET" && path === `/${grant}`) return page(grant); - if (grant === "query") { - if (request.method === "GET" && path === "/query/api/stores") return json(await adapters.read.list(operator)); - if (request.method === "POST" && path === "/query/api/ask") { - let input; - try { - input = await body(request); - } catch (error) { - return bodyFailure(error); - } - if (!object(input) || typeof input.store !== "string" || typeof input.question !== "string" || !input.question.trim() || !adapters.stores.some((store) => store.id === input.store)) return refusal("ConsoleRequestMalformed", 400); - return json(await adapters.turn({ operator, store: input.store, question: input.question })); - } - } else { - const store = url.searchParams.get("store"); - if (store !== null && !adapters.stores.some((entry) => entry.id === store)) return refusal("ConsoleRequestMalformed", 400); - if (request.method === "GET" && path === "/admin/api/workflows") return json(await adapters.control.workflows(operator, store)); - if (request.method === "GET" && path === "/admin/api/record") return json(await adapters.control.record(operator, store)); - if (request.method === "GET" && path === "/admin/api/packs") { - if (!store || !adapters.stores.some((entry) => entry.id === store)) return refusal("ConsoleRequestMalformed", 400); - if (!adapters.control.listPackFiles) return refusal("ConsoleAdapterUnavailable", 503); - return json(await adapters.control.listPackFiles(operator, store, url.searchParams.get("prefix") ?? "")); - } - if (request.method === "POST" && (path === "/admin/api/edit" || path === "/admin/api/apply")) { - if (!adapters.adminCapability) return refusal("ConsoleAdminGrantMissing"); - const origin = request.headers.get("origin"); - if (origin && origin !== url.origin) return refusal("ConsolePageForbidden"); - let document; - try { - document = await body(request); - } catch (error) { - return bodyFailure(error); - } - return json(path.endsWith("/edit") ? await adapters.control.edit(document, adapters.adminCapability, operator) : await adapters.control.apply(document, adapters.adminCapability, operator)); - } - } - return refusal("ConsoleRouteNotFound", 404); - } - }; -} -var sharedStyle = ``; -var queryPage = `Query \xB7 Contextful${sharedStyle}

Ask the store

Answers cite the rows your access permits.

Conversation

Your answer appears here.

Results

`; -var adminPage = `Admin \xB7 Contextful${sharedStyle}

Store operations

Pipelines, schedules, steps and run outcomes come from the store.

Workflow canvas

Operational record

Control document

`; - -// src/server.ts -var maxBodyBytes = 1048576; -async function receive(message, origin) { - const method = message.method ?? "GET"; - const chunks = []; - let size = 0; - for await (const chunk of message) { - size += chunk.length; - if (size > maxBodyBytes) throw new Error("ConsoleBodyTooLarge"); - chunks.push(Buffer.from(chunk)); - } - const body2 = method === "GET" || method === "HEAD" ? void 0 : Buffer.concat(chunks); - const headers = new Headers(); - for (const [key, value] of Object.entries(message.headers)) { - if (typeof value === "string") headers.set(key, value); - else if (Array.isArray(value)) headers.set(key, value.join(", ")); - } - return new Request(new URL(message.url ?? "/", origin), { method, headers, body: body2 }); -} -async function send(reply, response) { - reply.statusCode = response.status; - response.headers.forEach((value, key) => reply.setHeader(key, value)); - reply.end(Buffer.from(await response.arrayBuffer())); -} -function serveConsole(adapters, origin) { - const app = createConsole(adapters); - return createServer(async (message, reply) => { - try { - await send(reply, await app.fetch(await receive(message, typeof origin === "string" ? origin : origin()))); - } catch (error) { - if (error instanceof Error && error.message === "ConsoleBodyTooLarge") { - await send(reply, Response.json({ error: { identifier: "ConsoleBodyTooLarge" } }, { status: 413 })); - return; - } - const unavailable2 = error instanceof Error && error.message === "ConsoleAdapterUnavailable"; - await send(reply, Response.json({ error: { identifier: unavailable2 ? "ConsoleAdapterUnavailable" : "ConsoleServerFailure" } }, { status: unavailable2 ? 503 : 500 })); - } - }); -} - -// src/entry.ts -function required(name) { - const value = process.env[name]; - if (!value) throw new Error(`${name} is required`); - return value; -} -async function keysAt(url) { - const response = await fetch(url); - if (!response.ok) throw new Error(`identity key route returned ${response.status}`); - const document = await response.json(); - if (!document || typeof document !== "object" || !("keys" in document) || !Array.isArray(document.keys)) { - throw new Error("identity key route returned no key set"); - } - const keys = /* @__PURE__ */ new Map(); - for (const value of document.keys) { - if (!value || typeof value !== "object" || value.kty !== "RSA" || value.alg !== "RS256" || typeof value.kid !== "string") continue; - keys.set(value.kid, createPublicKey2({ key: value, format: "jwk" })); - } - if (keys.size === 0) throw new Error("identity key route returned no RS256 key"); - return keys; -} -async function identity() { - if (process.env.CONTEXTFUL_IDENTITY === "cognito") { - const keys = await keysAt(required("CONTEXTFUL_COGNITO_JWKS_URL")); - return { - kind: "cognito", - sessionSecret: required("CONTEXTFUL_COGNITO_SESSION_SECRET"), - queryGroup: required("CONTEXTFUL_COGNITO_QUERY_GROUP"), - adminGroup: required("CONTEXTFUL_COGNITO_ADMIN_GROUP"), - issuer: required("CONTEXTFUL_COGNITO_ISSUER"), - clientId: required("CONTEXTFUL_COGNITO_CLIENT_ID"), - authorizeUrl: required("CONTEXTFUL_COGNITO_AUTHORIZE_URL"), - tokenUrl: required("CONTEXTFUL_COGNITO_TOKEN_URL"), - redirectUri: required("CONTEXTFUL_COGNITO_REDIRECT_URI"), - keys - }; - } - return { - kind: "access", - issuer: required("CONTEXTFUL_ACCESS_ISSUER"), - queryAudience: required("CONTEXTFUL_QUERY_ACCESS_AUDIENCE"), - adminAudience: required("CONTEXTFUL_ADMIN_ACCESS_AUDIENCE"), - keys: await keysAt(required("CONTEXTFUL_ACCESS_JWKS_URL")) - }; -} -function unavailable() { - throw new Error("ConsoleAdapterUnavailable"); -} -function unavailableAdapters() { - return { - turn: async () => unavailable(), - control: { - workflows: async () => unavailable(), - record: async () => unavailable(), - edit: async () => unavailable(), - apply: async () => unavailable() - } - }; -} -async function main() { - const addressFlag = process.argv.indexOf("--http"); - if (addressFlag < 0 || !process.argv[addressFlag + 1]) throw new Error("--http is required"); - const address = process.argv[addressFlag + 1]; - const separator = address.lastIndexOf(":"); - const host = address.slice(0, separator); - const port = Number(address.slice(separator + 1)); - if (!host || !Number.isInteger(port) || port < 0 || port > 65535) throw new Error("invalid --http address"); - const registry = registryFromEnv(process.env.CONTEXTFUL_STORES_JSON); - const stores = registry.entries.map(({ id, label }) => ({ id, label })); - let adapters = unavailableAdapters(); - const modulePath = process.env.CONTEXTFUL_CONSOLE_ADAPTER_MODULE; - if (modulePath) { - const absolute = isAbsolute(modulePath) ? modulePath : resolve(modulePath); - const module = await import(pathToFileURL(absolute).href); - if (!module.createAdapters) throw new Error("console adapter module exports no createAdapters"); - adapters = await module.createAdapters({ stores, env: process.env }); - } - let origin = process.env.CONTEXTFUL_CONSOLE_ORIGIN ?? `http://${host}:${port}`; - const app = serveConsole({ - identity: await identity(), - stores, - adminCapability: process.env.CONTEXTFUL_ADMIN_CAPABILITY, - turn: adapters.turn, - read: adapters.read ?? { list: async () => stores }, - control: adapters.control - }, () => origin); - await new Promise((resolveListen, rejectListen) => { - app.once("error", rejectListen); - app.listen(port, host, resolveListen); - }); - const bound = app.address(); - if (!bound || typeof bound === "string") throw new Error("console listener has no address"); - if (!process.env.CONTEXTFUL_CONSOLE_ORIGIN) origin = `http://${host}:${bound.port}`; - process.stderr.write(`listening on http://${host}:${bound.port} -`); -} -main().catch((error) => { - process.stderr.write(`${error instanceof Error ? error.message : "console startup failed"} -`); - process.exitCode = 1; -}); From 90efb773d0206964ea6dfe23220f1c73d1587789 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 9 Oct 2026 00:54:59 +0800 Subject: [PATCH 13/14] test(console): require a Lax session cookie after Cognito login --- apps/console/test/console.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/console/test/console.test.ts b/apps/console/test/console.test.ts index 26da13e41..4b869f1f4 100644 --- a/apps/console/test/console.test.ts +++ b/apps/console/test/console.test.ts @@ -157,6 +157,7 @@ test("Cognito hosted login exchanges a code before issuing a first-party session const callback = await app.fetch(new Request(`https://console.example/auth/callback?code=accepted&state=${state}`, { headers: { cookie: stateCookie } })); assert.equal(callback.status, 302); assert.equal(callback.headers.get("location"), "/admin"); + assert.match(callback.headers.get("set-cookie")!, /; SameSite=Lax;/, "the session cookie rides the redirect that completes a cross-site login"); const sessionCookie = callback.headers.get("set-cookie")!.split(";")[0]; assert.equal((await app.fetch(new Request("https://console.example/admin", { headers: { cookie: sessionCookie } }))).status, 200); assert.equal((await app.fetch(new Request("https://console.example/query", { headers: { cookie: sessionCookie } }))).status, 403); From f1b0c9f09b507e8245b554b5bb38ff121e6b0db6 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Fri, 9 Oct 2026 00:55:05 +0800 Subject: [PATCH 14/14] fix(console): issue the Cognito session cookie as SameSite=Lax --- apps/console/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/console/src/index.ts b/apps/console/src/index.ts index beab27127..779ae24da 100644 --- a/apps/console/src/index.ts +++ b/apps/console/src/index.ts @@ -184,7 +184,7 @@ async function cognitoCallback(request: Request, identity: CognitoIdentity): Pro const destination = groups.includes(identity.adminGroup) ? "/admin" : "/query"; return new Response(null, { status: 302, headers: { Location: destination, - "Set-Cookie": `console_session=${session}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${lifetime - Math.floor(Date.now() / 1000)}`, + "Set-Cookie": `console_session=${session}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${lifetime - Math.floor(Date.now() / 1000)}`, "Cache-Control": "no-store", } }); }