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/package.json b/apps/console/package.json new file mode 100644 index 000000000..8b68b29ef --- /dev/null +++ b/apps/console/package.json @@ -0,0 +1,15 @@ +{ + "name": "@contextful/console", + "private": true, + "type": "module", + "scripts": { + "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 new file mode 100644 index 000000000..63aff606d --- /dev/null +++ b/apps/console/pnpm-lock.yaml @@ -0,0 +1,310 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +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..0ee90294c --- /dev/null +++ b/apps/console/src/entry.ts @@ -0,0 +1,113 @@ +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")); + 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(): 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 }); + } + 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}\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/index.ts b/apps/console/src/index.ts new file mode 100644 index 000000000..779ae24da --- /dev/null +++ b/apps/console/src/index.ts @@ -0,0 +1,308 @@ +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 }; +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; + keys?: ReadonlyMap; + 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 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=${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 && !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 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, code_verifier: verifier }), + }); + 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 = 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); + 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=Lax; 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 { + 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 { + 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 (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 (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: unknown; + 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); + }, + }; +} + +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..c5aa76f6c --- /dev/null +++ b/apps/console/src/server.ts @@ -0,0 +1,45 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +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[] = []; + 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)) { + 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 | (() => string)): Server { + 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 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 new file mode 100644 index 000000000..4b869f1f4 --- /dev/null +++ b/apps/console/test/console.test.ts @@ -0,0 +1,300 @@ +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"; +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, 401); + assert.equal((await request(path, { "cf-access-jwt-assertion": "forged" })).status, 401); + } + 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, 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 () => { + 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), 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)); + assert.equal((await workflows.json() as { pipelines: unknown[] }).pipelines.length, 1); + 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), 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), origin }, { 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, 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/); + 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 }) }); + }, + }, + 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); + 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); + 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); + 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 () => { + 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("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("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({ + 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/"]); +}); 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"] +} 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();