diff --git a/Cargo.lock b/Cargo.lock index 1e3447237..f267e499f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,6 +853,7 @@ dependencies = [ "contextful-sync", "contextful-wasm", "ed25519-dalek", + "hmac 0.12.1", "jsonwebtoken", "proptest", "proptest-state-machine", diff --git a/apps/console/src/control.ts b/apps/console/src/control.ts new file mode 100644 index 000000000..543bcb084 --- /dev/null +++ b/apps/console/src/control.ts @@ -0,0 +1,67 @@ +import type { ConsoleAdapters } from "./index.ts"; +import { createHash, createHmac, randomBytes } from "node:crypto"; + +type ControlStore = { id: string; endpoint: string }; +type ControlOptions = { stores: ControlStore[]; capability?: string; attestationSecret?: string; fetcher?: typeof fetch }; + +function unavailable(): never { + throw new Error("ConsoleAdapterUnavailable"); +} + +function selected(stores: ControlStore[], id: string | null): ControlStore { + if (id !== null) { + const store = stores.find((candidate) => candidate.id === id); + if (store) return store; + throw new Error("ConsoleStoreUnknown"); + } + if (stores.length === 1) return stores[0]; + throw new Error("ConsoleStoreSelectionRequired"); +} + +function controlDocument(document: unknown, editing: boolean): { store: string | null; body: { expected: number; document?: string; nonce?: string } } { + if (document === null || typeof document !== "object" || Array.isArray(document)) throw new Error("ConsoleRequestMalformed"); + const value = document as Record; + if (value.store !== undefined && typeof value.store !== "string") throw new Error("ConsoleRequestMalformed"); + if (!Number.isSafeInteger(value.expected) || (value.expected as number) < 1) throw new Error("ConsoleRequestMalformed"); + if (editing && typeof value.document !== "string") throw new Error("ConsoleRequestMalformed"); + if (!editing && (typeof value.nonce !== "string" || !/^[0-9a-f]{48}$/.test(value.nonce))) throw new Error("ConsoleRequestMalformed"); + const keys = Object.keys(value); + if (keys.some((key) => key !== "store" && key !== "expected" && (key !== "document" || !editing) && (key !== "nonce" || editing))) throw new Error("ConsoleRequestMalformed"); + return { store: typeof value.store === "string" ? value.store : null, + body: editing ? { expected: value.expected as number, document: value.document as string } : { expected: value.expected as number, nonce: value.nonce as string } }; +} + +export function createLiveControl({ stores, capability, attestationSecret, fetcher = fetch }: ControlOptions): ConsoleAdapters["control"] { + async function call(store: ControlStore, path: string, token: string, body?: object, subject?: string): Promise { + const serialized = body ? JSON.stringify(body) : undefined; + const headers: Record = { Authorization: `Bearer ${token}`, ...(body ? { "Content-Type": "application/json" } : {}) }; + if (body && subject && attestationSecret) { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = randomBytes(16).toString("hex"); + const digest = createHash("sha256").update(serialized!).digest("hex"); + const signature = createHmac("sha256", attestationSecret).update(`POST\n${path}\n${digest}\n${subject}\n${timestamp}\n${nonce}`).digest("hex"); + Object.assign(headers, { "X-Contextful-Operator": subject, "X-Contextful-Operator-Time": timestamp, "X-Contextful-Operator-Nonce": nonce, "X-Contextful-Operator-Signature": signature }); + } + const response = await fetcher(new URL(path, store.endpoint), { + method: body ? "POST" : "GET", + headers, + ...(serialized ? { body: serialized } : {}), + }); + if (!response.ok) throw new Error(`ConsoleControlRefused:${response.status}`); + return response.json(); + } + return { + workflows: async (_operator, id) => capability ? call(selected(stores, id), "/control/workflows", capability) : unavailable(), + record: async (_operator, id) => capability ? call(selected(stores, id), "/control/record", capability) : unavailable(), + edit: async (document, token, operator) => { + if (!capability || token !== capability) return unavailable(); + const input = controlDocument(document, true); + return call(selected(stores, input.store), "/control/edit", token, input.body, operator.subject); + }, + apply: async (document, token, operator) => { + if (!capability || token !== capability) return unavailable(); + const input = controlDocument(document, false); + return call(selected(stores, input.store), "/control/apply", token, input.body, operator.subject); + }, + }; +} diff --git a/apps/console/src/entry.ts b/apps/console/src/entry.ts index bfe23b4c7..e4b16f7eb 100644 --- a/apps/console/src/entry.ts +++ b/apps/console/src/entry.ts @@ -5,6 +5,7 @@ import { registryFromEnv } from "../../gateway/src/index.ts"; import { createLiveAnswer } from "./answer_live.ts"; import { createLiveBrowse } from "./live_browse.ts"; import type { ConsoleAdapters, Identity } from "./index.ts"; +import { createLiveControl } from "./control.ts"; import { serveConsole } from "./server.ts"; type HostedAdapters = Pick & @@ -95,6 +96,8 @@ async function main(): Promise { brief: live?.brief, briefBudgetMs: live?.briefBudgetMs, redactView: live?.redactView, + control: createLiveControl({ stores: registry.entries, capability: process.env.CONTEXTFUL_ADMIN_CAPABILITY, + attestationSecret: process.env.CONTEXTFUL_ADMIN_CAPABILITY ? required("CONTEXTFUL_CONTROL_ATTESTATION_SECRET") : undefined }), }; if (modulePath) { const absolute = isAbsolute(modulePath) ? modulePath : resolve(modulePath); diff --git a/apps/console/src/index.ts b/apps/console/src/index.ts index 43a3814ac..c1cf35a01 100644 --- a/apps/console/src/index.ts +++ b/apps/console/src/index.ts @@ -346,6 +346,7 @@ export function createConsole(adapters: ConsoleAdapters): { fetch: (request: Req } 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/stores") return json(adapters.stores); 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") { @@ -386,6 +387,10 @@ store.addEventListener('change',()=>{void showBrief();void refreshBrowse()});asO form.addEventListener('submit',async event=>{event.preventDefault();const question=questionField.value.trim();if(!question)return;brief.hidden=true;const response=await fetch('/query/api/ask',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({store:store.value,question})});const answer=await response.json();transcript.replaceChildren();const item=document.createElement('article');item.textContent=question+'\\n'+(answer.answer??answer.error?.identifier??'No answer');transcript.append(item);widgets.replaceChildren();for(const widget of answer.widgets??[])widgets.append(draw(widget));if(answer.sources?.length){const sources=document.createElement('p');sources.textContent='Sources: '+answer.sources.map(x=>x.title??x.url).join(', ');transcript.append(sources)}}); `; -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/test/console.test.ts b/apps/console/test/console.test.ts index 7fc094e75..35c26e30c 100644 --- a/apps/console/test/console.test.ts +++ b/apps/console/test/console.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { Script } from "node:vm"; import { generateKeyPairSync, sign } from "node:crypto"; import { createConsole, issueCognitoSession } from "../src/index.ts"; import { serveConsole } from "../src/server.ts"; @@ -94,6 +95,12 @@ 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"/); + for (const id of ["brief", "chips", "insights", "file-gallery", "file-preview"]) { + assert.match(page, new RegExp(`id="${id}"`)); + } + const script = page.match(/