From cc748ef15f46e991ac1e6785cd6c0433bdb0680f Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:42:28 +0800 Subject: [PATCH 01/10] test(console): specify governed browse discovery --- apps/console/test/browse.test.ts | 66 ++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 apps/console/test/browse.test.ts diff --git a/apps/console/test/browse.test.ts b/apps/console/test/browse.test.ts new file mode 100644 index 000000000..89f203b1e --- /dev/null +++ b/apps/console/test/browse.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { BrowseError, createBrowse, humanizeLabel } from "../src/browse.ts"; + +function fixture() { + const calls: Array<{ tool: string; arguments: Record }> = []; + const transport = { + async call(tool: string, args: Record) { + calls.push({ tool, arguments: args }); + if (tool === "context.describe" && !args.table) return { structuredContent: { + tables: [ + { table: "research/vendor_filings", description: "Filed reports", zone_admitted: true }, + { table: "research/private_payroll", description: "Restricted", zone_admitted: false }, + ], + } }; + if (tool === "context.describe") return { structuredContent: { table: args.table, row_count: 3 } }; + if (tool === "context.files") return { structuredContent: { + columns: ["table", "path"], + rows: [ + ["research/vendor_filings", "research/vendor_filings/runs/run-1/report.parquet"], + ["research/private_payroll", "research/private_payroll/runs/run-1/secret.parquet"], + ], + } }; + if (tool === "context.file") return { structuredContent: { columns: ["title"], rows: [["Q1 report"]] } }; + throw new Error(`unexpected tool ${tool}`); + }, + }; + return { browse: createBrowse(transport), calls }; +} + +test("browse advertises admitted tables as humanized chips and governed insights", async () => { + const { browse, calls } = fixture(); + const result = await browse.discover({ asOf: "2030-01-01T00:00:00Z" }); + assert.deepEqual(result.chips, [{ table: "research/vendor_filings", label: "Vendor Filings", description: "Filed reports" }]); + assert.deepEqual(result.insights, [{ table: "research/vendor_filings", label: "Vendor Filings", rows: 3 }]); + assert.deepEqual(result.files, [{ table: "research/vendor_filings", path: "research/vendor_filings/runs/run-1/report.parquet", label: "Report" }]); + assert.equal(calls[0].tool, "context.describe"); + assert.equal(calls[0].arguments.as_of, "2030-01-01T00:00:00Z"); + assert(calls.every((call) => call.tool.startsWith("context."))); + assert(!calls.some((call) => call.arguments.table === "research/private_payroll")); +}); + +test("a gallery preview refuses paths absent from the governed listing", async () => { + const { browse, calls } = fixture(); + await assert.rejects(browse.preview({ path: "research/vendor_filings/runs/run-1/other.parquet" }), + (error: unknown) => error instanceof BrowseError && error.code === "ConsoleGalleryPathUnlisted"); + assert(!calls.some((call) => call.tool === "context.file")); +}); + +test("a gallery preview uses context.file with the listing's snapshot bound", async () => { + const { browse, calls } = fixture(); + const preview = await browse.preview({ path: "research/vendor_filings/runs/run-1/report.parquet", asOf: "2030-01-01T00:00:00Z" }); + assert.deepEqual(preview, { columns: ["title"], rows: [["Q1 report"]] }); + assert.deepEqual(calls.at(-1), { tool: "context.file", arguments: { path: "research/vendor_filings/runs/run-1/report.parquet", as_of: "2030-01-01T00:00:00Z" } }); +}); + +test("in-band read refusals remain errors", async () => { + const browse = createBrowse({ call: async () => ({ isError: true, structuredContent: { error: { identifier: "EnforceUnknownRelation" } } }) }); + await assert.rejects(browse.discover({}), + (error: unknown) => error instanceof BrowseError && error.code === "EnforceUnknownRelation"); +}); + +test("human labels handle separators and acronyms without adding prompts", () => { + assert.equal(humanizeLabel("reports/q3_sec_filings"), "Q3 SEC Filings"); + assert.equal(humanizeLabel("customer-orders"), "Customer Orders"); +}); From 11717d0e7cb6d7ec32a4a13169cea191e7da5b86 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 13:45:20 +0800 Subject: [PATCH 02/10] feat(console): derive browse views from governed reads --- apps/console/src/browse.ts | 91 ++++++++++++++++++++++++++++++++++++++ spec/71-console.md | 13 ++++++ spec/cards/surface.md | 3 +- spec/spec.lock.json | 81 ++++++++++++++++++++++++++------- spec/terms/surface.toml | 1 + 5 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 apps/console/src/browse.ts diff --git a/apps/console/src/browse.ts b/apps/console/src/browse.ts new file mode 100644 index 000000000..135da2332 --- /dev/null +++ b/apps/console/src/browse.ts @@ -0,0 +1,91 @@ +type ToolResult = { + structuredContent?: unknown; + isError?: boolean; +}; + +type ReadTransport = { + call(tool: string, arguments_: Record): Promise; +}; + +type TableEntry = { table: string; description?: string; zone_admitted?: boolean }; +type FileEntry = { table: string; path: string; label: string }; +type BrowseRequest = { asOf?: string }; + +export class BrowseError extends Error { + readonly code: string; + + constructor(code: string, message = code) { + super(message); + this.name = "BrowseError"; + this.code = code; + } +} + +const abbreviations = new Set(["API", "CSV", "PDF", "SEC", "SQL", "URL"]); + +export function humanizeLabel(identifier: string): string { + const tail = identifier.split("/").at(-1) ?? identifier; + return tail.replace(/\.[^.]+$/, "").replace(/([a-z])([A-Z])/g, "$1 $2") + .split(/[-_\s]+/).filter(Boolean) + .map((part) => { + const upper = part.toUpperCase(); + return abbreviations.has(upper) || /^[Qq]\d+$/.test(part) + ? upper : part[0].toUpperCase() + part.slice(1).toLowerCase(); + }).join(" "); +} + +function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new BrowseError("ConsoleBrowseResponseMalformed"); + return value as Record; +} + +function bounds(request: BrowseRequest): Record { + return request.asOf ? { as_of: request.asOf } : {}; +} + +export function createBrowse(transport: ReadTransport) { + async function read(tool: string, arguments_: Record): Promise> { + const result = await transport.call(tool, arguments_); + if (result.isError) { + const error = object(object(result.structuredContent).error); + throw new BrowseError(String(error.identifier ?? "ConsoleBrowseReadRefused")); + } + return object(result.structuredContent); + } + + async function listed(request: BrowseRequest): Promise<{ tables: TableEntry[]; files: FileEntry[] }> { + const listing = await read("context.describe", bounds(request)); + if (!Array.isArray(listing.tables)) throw new BrowseError("ConsoleBrowseResponseMalformed"); + const tables = listing.tables.map((item) => object(item)).filter((item) => item.zone_admitted === true && typeof item.table === "string") + .map((item) => ({ table: item.table as string, description: typeof item.description === "string" ? item.description : undefined })); + const names = new Set(tables.map((table) => table.table)); + const fileListing = await read("context.files", bounds(request)); + if (!Array.isArray(fileListing.rows)) throw new BrowseError("ConsoleBrowseResponseMalformed"); + const files = fileListing.rows.filter((row): row is [string, string] => + Array.isArray(row) && typeof row[0] === "string" && typeof row[1] === "string" && names.has(row[0])) + .map(([table, path]) => ({ table, path, label: humanizeLabel(path) })); + return { tables, files }; + } + + return { + async discover(request: BrowseRequest) { + const { tables, files } = await listed(request); + const chips = tables.map((table) => ({ table: table.table, label: humanizeLabel(table.table), description: table.description })); + const descriptions = await Promise.all(tables.map((table) => read("context.describe", { table: table.table, ...bounds(request) }))); + const insights = tables.flatMap((table, index) => { + const count = descriptions[index].row_count; + return typeof count === "number" && Number.isFinite(count) && count >= 0 + ? [{ table: table.table, label: humanizeLabel(table.table), rows: count }] : []; + }); + return { chips, insights, files }; + }, + + async preview(request: BrowseRequest & { path: string }) { + const { files } = await listed(request); + if (!files.some((file) => file.path === request.path)) throw new BrowseError("ConsoleGalleryPathUnlisted", request.path); + const response = await read("context.file", { path: request.path, ...bounds(request) }); + if (!Array.isArray(response.columns) || !Array.isArray(response.rows)) throw new BrowseError("ConsoleBrowseResponseMalformed"); + return { columns: response.columns, rows: response.rows }; + }, + }; +} diff --git a/spec/71-console.md b/spec/71-console.md index d16cf41b5..6f6981002 100644 --- a/spec/71-console.md +++ b/spec/71-console.md @@ -276,6 +276,19 @@ A session's time basis: its vantage, the bound each leg carries, the snapshot ti What a store advertises before a question: discovered chips, humanized labels, the insights panel and the file gallery. +- `discovered-chips` — Query builds pre-question chips from described tables admitted to the reader's zone and humanizes their table labels. + *A-surface* +- `insights-panel` — The insights panel presents row counts returned by the reader's table descriptions. + *because a row count from the read face already reflects the reader's relation* +- `file-gallery` — The file gallery presents only paths returned by the reader's governed file listing and previews them through the read face. + *P5* +- `gallery-path-unlisted` — A gallery preview naming a path absent from the governed file listing under the request's `as_of` raises `ConsoleGalleryPathUnlisted` before preview dispatch. + *because the browser must not turn an arbitrary path into a store read* + +#### Scenarios + +- `surface.browse.gallery-path-unlisted`: WHEN a Query client requests a path absent from the reader's bound file listing, THEN it raises `ConsoleGalleryPathUnlisted` without a preview call. + ## learn The reading loop's memory: recall ahead of planning, the per-turn distillation and the labels its conclusions inherit. diff --git a/spec/cards/surface.md b/spec/cards/surface.md index a4c8ee8e4..741bd68ee 100644 --- a/spec/cards/surface.md +++ b/spec/cards/surface.md @@ -21,7 +21,7 @@ Generated by `contextful-spec state` from the contract's files and `spec/terms/s | `ground` | Where an answer's material comes from: the closed tool set, the two trust layers, the per-reader credential, the web supplement and the sources block. | 8 | | `plan-turn` | The shape of one turn: planner scaffolding, the replanning round, the answerability test, the code path, and the store's prompt overlay. | 6 | | `set-vantage` | A session's time basis: its vantage, the bound each leg carries, the snapshot timeline and the arrivals strip. | 3 | -| `browse` | What a store advertises before a question: discovered chips, humanized labels, the insights panel and the file gallery. | 0 | +| `browse` | What a store advertises before a question: discovered chips, humanized labels, the insights panel and the file gallery. | 4 | | `learn` | The reading loop's memory: recall ahead of planning, the per-turn distillation and the labels its conclusions inherit. | 3 | | `render` | The three channels, the component union, deterministic component choice, view hints and the sanitizer walk. | 3 | | `brief` | The proactive greeting card: its derivation, payload, matching tiers, client-side clock and the absence it earns. | 6 | @@ -78,6 +78,7 @@ Generated by `contextful-spec state` from the contract's files and `spec/terms/s | `ConsolePlannerReachedMemory` | Planner scaffolding naming a memory relation. | `surface.plan-turn.planner-reached-memory` | | `ConsoleVantageUnparseable` | A vantage that is neither a calendar day nor an instant. | `surface.set-vantage.unparseable` | | `ConsoleWebBoundUnparseable` | A web leg whose publication bound does not parse. | `surface.set-vantage.web-bound-unparseable` | +| `ConsoleGalleryPathUnlisted` | A gallery preview path absent from the reader's governed file listing. | `surface.browse.gallery-path-unlisted` | | `ConsoleLearningUnscoped` | A distilled conclusion landing without the reading-session scope. | `surface.learn.unscoped` | | `ConsoleViewNotServerBuilt` | A view from a client or the model rather than inferred from a result. | `surface.render.client-authored-view` | | `ConsoleBriefUnavailable` | A greeting derivation that errored or overran its budget. | `surface.brief.absence-is-earned` | diff --git a/spec/spec.lock.json b/spec/spec.lock.json index 882ecab71..57e63f542 100644 --- a/spec/spec.lock.json +++ b/spec/spec.lock.json @@ -20687,6 +20687,53 @@ "file": "spec/71-console.md", "line": 273 }, + { + "id": "surface.browse.discovered-chips", + "contract": "surface", + "operation": "browse", + "subject": "discovered-chips", + "kind": "behavior", + "statement": "Query builds pre-question chips from described tables admitted to the reader's zone and humanizes their table labels.", + "why": "A-surface", + "file": "spec/71-console.md", + "line": 279 + }, + { + "id": "surface.browse.insights-panel", + "contract": "surface", + "operation": "browse", + "subject": "insights-panel", + "kind": "behavior", + "statement": "The insights panel presents row counts returned by the reader's table descriptions.", + "why": "because a row count from the read face already reflects the reader's relation", + "file": "spec/71-console.md", + "line": 281 + }, + { + "id": "surface.browse.file-gallery", + "contract": "surface", + "operation": "browse", + "subject": "file-gallery", + "kind": "behavior", + "statement": "The file gallery presents only paths returned by the reader's governed file listing and previews them through the read face.", + "why": "P5", + "file": "spec/71-console.md", + "line": 283 + }, + { + "id": "surface.browse.gallery-path-unlisted", + "contract": "surface", + "operation": "browse", + "subject": "gallery-path-unlisted", + "kind": "refusal", + "statement": "A gallery preview naming a path absent from the governed file listing under the request's `as_of` raises `ConsoleGalleryPathUnlisted` before preview dispatch.", + "why": "because the browser must not turn an arbitrary path into a store read", + "file": "spec/71-console.md", + "line": 285, + "scenarios": [ + "WHEN a Query client requests a path absent from the reader's bound file listing, THEN it raises `ConsoleGalleryPathUnlisted` without a preview call." + ] + }, { "id": "surface.learn.distillation", "contract": "surface", @@ -20696,7 +20743,7 @@ "statement": "After the answer streams, a second pass distils the exchange into at most 3 entries shaped `{subject, key, learning}`, zero included.", "why": "", "file": "spec/71-console.md", - "line": 283 + "line": 296 }, { "id": "surface.learn.unscoped", @@ -20707,7 +20754,7 @@ "statement": "A distilled conclusion landing without the reading-session scope raises `ConsoleLearningUnscoped`.", "why": "A-surface", "file": "spec/71-console.md", - "line": 284 + "line": 297 }, { "id": "surface.learn.subject-resolution", @@ -20718,7 +20765,7 @@ "statement": "Distillation preserves the subject it observes; recall resolves that subject through entity matching.", "why": "A-surface", "file": "spec/71-console.md", - "line": 286 + "line": 299 }, { "id": "surface.render.client-authored-view", @@ -20729,7 +20776,7 @@ "statement": "A view specification arriving from a client, or composed by the model, raises `ConsoleViewNotServerBuilt`.", "why": "A-surface", "file": "spec/71-console.md", - "line": 293 + "line": 306 }, { "id": "surface.render.component-choice", @@ -20740,7 +20787,7 @@ "statement": "One row with one measure draws a metric; a date column with a measure over 3 rows on distinct days draws a line; anything else draws a table offering a bar view.", "why": "", "file": "spec/71-console.md", - "line": 295 + "line": 308 }, { "id": "surface.render.older-transcript", @@ -20751,7 +20798,7 @@ "statement": "A client reading a transcript with an unknown component-union member renders its view as a table.", "why": "A-surface", "file": "spec/71-console.md", - "line": 296 + "line": 309 }, { "id": "surface.brief.absence-is-earned", @@ -20762,7 +20809,7 @@ "statement": "The card needs a session with no turns, at present time, a live conclusion, an arrived row matching an interest, and a derivation inside its budget. An error or timeout raises `ConsoleBriefUnavailable`, and no card renders.", "why": "A-surface", "file": "spec/71-console.md", - "line": 332 + "line": 345 }, { "id": "surface.brief.topic-tier", @@ -20773,7 +20820,7 @@ "statement": "The topic tier needs at least 2 tokens shared between the conclusion's subject and text and the row's label and topics, one of them naming the subject.", "why": "", "file": "spec/71-console.md", - "line": 334 + "line": 347 }, { "id": "surface.brief.card-subjects", @@ -20784,7 +20831,7 @@ "statement": "A card carries at most 3 subjects.", "why": "", "file": "spec/71-console.md", - "line": 335 + "line": 348 }, { "id": "surface.brief.articles-per-subject", @@ -20795,7 +20842,7 @@ "statement": "A subject carries at most 3 entries of matched article.", "why": "", "file": "spec/71-console.md", - "line": 336 + "line": 349 }, { "id": "surface.brief.catchup-window", @@ -20806,7 +20853,7 @@ "statement": "The backend caps the requested window at 7 d.", "why": "", "file": "spec/71-console.md", - "line": 337 + "line": 350 }, { "id": "surface.brief.greeting", @@ -20817,7 +20864,7 @@ "statement": "Query derives a greeting card from arrived rows matching live conclusions in a turnless present-time session.", "why": "A-surface", "file": "spec/71-console.md", - "line": 338 + "line": 351 }, { "id": "surface.publish-answer.share-affordance", @@ -20828,7 +20875,7 @@ "statement": "A surface offering a share control on access-explanation output raises `VisibilityShareAffordance`.", "why": "A-surface", "file": "spec/71-console.md", - "line": 345 + "line": 358 }, { "id": "surface.publish-answer.askerless-audience", @@ -20839,7 +20886,7 @@ "statement": "A scheduled job posting to an audience under a service identity raises `VisibilityAskerlessAudience`, naming the destination; a scheduled post's corpus is narrowed in the reviewed manifest to what the destination reaches.", "why": "A-surface", "file": "spec/71-console.md", - "line": 347 + "line": 360 }, { "id": "surface.publish-answer.one-operator-principal", @@ -20850,7 +20897,7 @@ "statement": "An answer uses one operator's credential; the console offers no room-intersection principal.", "why": "A-surface", "file": "spec/71-console.md", - "line": 349 + "line": 362 }, { "id": "assurance.model.package", @@ -29988,6 +30035,10 @@ "clause": "surface.ground.direct-file-read", "gloss": "A table function reading stored bytes by path on the caller surface." }, + "ConsoleGalleryPathUnlisted": { + "clause": "surface.browse.gallery-path-unlisted", + "gloss": "A gallery preview path absent from the reader's governed file listing." + }, "ConsoleLearningUnscoped": { "clause": "surface.learn.unscoped", "gloss": "A distilled conclusion landing without the reading-session scope." diff --git a/spec/terms/surface.toml b/spec/terms/surface.toml index acd17c663..332d7837d 100644 --- a/spec/terms/surface.toml +++ b/spec/terms/surface.toml @@ -70,6 +70,7 @@ ConsoleTokenExchangeRefused = { clause = "surface.ground.mint-refused", gloss ConsolePlannerReachedMemory = { clause = "surface.plan-turn.planner-reached-memory", gloss = "Planner scaffolding naming a memory relation." } ConsoleVantageUnparseable = { clause = "surface.set-vantage.unparseable", gloss = "A vantage that is neither a calendar day nor an instant." } ConsoleWebBoundUnparseable = { clause = "surface.set-vantage.web-bound-unparseable", gloss = "A web leg whose publication bound does not parse." } +ConsoleGalleryPathUnlisted = { clause = "surface.browse.gallery-path-unlisted", gloss = "A gallery preview path absent from the reader's governed file listing." } ConsoleLearningUnscoped = { clause = "surface.learn.unscoped", gloss = "A distilled conclusion landing without the reading-session scope." } ConsoleViewNotServerBuilt = { clause = "surface.render.client-authored-view", gloss = "A view from a client or the model rather than inferred from a result." } ConsoleBriefUnavailable = { clause = "surface.brief.absence-is-earned", gloss = "A greeting derivation that errored or overran its budget." } From 1d9f7ec2856ff3a17dce7a6877f3c84d51c46125 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 17:06:58 +0800 Subject: [PATCH 03/10] Pin governed browse tests to console clauses --- apps/console/test/browse.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/console/test/browse.test.ts b/apps/console/test/browse.test.ts index 89f203b1e..542101a39 100644 --- a/apps/console/test/browse.test.ts +++ b/apps/console/test/browse.test.ts @@ -28,11 +28,11 @@ function fixture() { return { browse: createBrowse(transport), calls }; } -test("browse advertises admitted tables as humanized chips and governed insights", async () => { +// spec: surface.browse.discovered-chips@def23028 +test("browse advertises admitted tables as humanized chips", async () => { const { browse, calls } = fixture(); const result = await browse.discover({ asOf: "2030-01-01T00:00:00Z" }); assert.deepEqual(result.chips, [{ table: "research/vendor_filings", label: "Vendor Filings", description: "Filed reports" }]); - assert.deepEqual(result.insights, [{ table: "research/vendor_filings", label: "Vendor Filings", rows: 3 }]); assert.deepEqual(result.files, [{ table: "research/vendor_filings", path: "research/vendor_filings/runs/run-1/report.parquet", label: "Report" }]); assert.equal(calls[0].tool, "context.describe"); assert.equal(calls[0].arguments.as_of, "2030-01-01T00:00:00Z"); @@ -40,6 +40,14 @@ test("browse advertises admitted tables as humanized chips and governed insights assert(!calls.some((call) => call.arguments.table === "research/private_payroll")); }); +// spec: surface.browse.insights-panel@b1d70f29 +test("insights show governed table row counts", async () => { + const { browse } = fixture(); + const result = await browse.discover({}); + assert.deepEqual(result.insights, [{ table: "research/vendor_filings", label: "Vendor Filings", rows: 3 }]); +}); + +// spec: surface.browse.gallery-path-unlisted@d8eb9504 test("a gallery preview refuses paths absent from the governed listing", async () => { const { browse, calls } = fixture(); await assert.rejects(browse.preview({ path: "research/vendor_filings/runs/run-1/other.parquet" }), @@ -47,6 +55,7 @@ test("a gallery preview refuses paths absent from the governed listing", async ( assert(!calls.some((call) => call.tool === "context.file")); }); +// spec: surface.browse.file-gallery@4cfe68e1 test("a gallery preview uses context.file with the listing's snapshot bound", async () => { const { browse, calls } = fixture(); const preview = await browse.preview({ path: "research/vendor_filings/runs/run-1/report.parquet", asOf: "2030-01-01T00:00:00Z" }); From c57b8016f0502988360dec2dd65a14f39a09639d Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 17:14:37 +0800 Subject: [PATCH 04/10] test(console): require hosted live governed browse --- apps/console/test/browse_hosted.test.ts | 51 +++++++++++++++++++++++++ apps/console/test/live_browse.test.ts | 48 +++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 apps/console/test/browse_hosted.test.ts create mode 100644 apps/console/test/live_browse.test.ts diff --git a/apps/console/test/browse_hosted.test.ts b/apps/console/test/browse_hosted.test.ts new file mode 100644 index 000000000..afc8c049c --- /dev/null +++ b/apps/console/test/browse_hosted.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createConsole, issueCognitoSession } from "../src/index.ts"; + +function hosted() { + const calls: string[] = []; + const app = createConsole({ + identity: { kind: "cognito", sessionSecret: "session-secret", queryGroup: "query", adminGroup: "admin" }, + stores: [{ id: "field-notes", label: "Field notes" }], + turn: async () => ({ answer: "", sources: [], widgets: [] }), + read: { list: async () => [] }, + browse: { + discover: async ({ operator, store, asOf }) => { calls.push(`discover:${operator.subject}:${store}:${asOf ?? ""}`); return { + chips: [{ table: "filings", label: "Filings" }], insights: [{ table: "filings", label: "Filings", rows: 3 }], files: [{ table: "filings", path: "filings/runs/run-1/report.parquet", label: "Report" }], + }; }, + preview: async ({ operator, store, path, asOf }) => { calls.push(`preview:${operator.subject}:${store}:${path}:${asOf ?? ""}`); return { columns: ["id"], rows: [["one"]] }; }, + }, + control: { workflows: async () => ({}), record: async () => ({}), edit: async () => ({}), apply: async () => ({}) }, + }); + const session = issueCognitoSession({ subject: "reader", groups: ["query"], expiresAt: Math.floor(Date.now() / 1000) + 60 }, "session-secret"); + const request = (path: string, body?: unknown, origin = "https://console.example") => app.fetch(new Request(`https://console.example${path}`, { + method: body === undefined ? "GET" : "POST", headers: { cookie: `console_session=${session}`, origin }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + })); + return { request, calls }; +} + +test("hosted Query exposes bounded same-origin browse and preview routes", async () => { + const { request, calls } = hosted(); + const page = await (await request("/query")).text(); + for (const id of ["chips", "insights", "file-gallery", "file-preview"]) assert.match(page, new RegExp(`id="${id}"`)); + assert.match(page, /\/query\/api\/browse/); + assert.match(page, /\/query\/api\/preview/); + const asOf = "2030-01-01T00:00:00Z"; + const browse = await request("/query/api/browse", { store: "field-notes", asOf }); + assert.equal(browse.status, 200); + assert.equal((await browse.json() as { chips: unknown[] }).chips.length, 1); + const preview = await request("/query/api/preview", { store: "field-notes", path: "filings/runs/run-1/report.parquet", asOf }); + assert.equal(preview.status, 200); + assert.deepEqual((await preview.json() as { rows: unknown[] }).rows, [["one"]]); + assert.deepEqual(calls, [`discover:reader:field-notes:${asOf}`, `preview:reader:field-notes:filings/runs/run-1/report.parquet:${asOf}`]); +}); + +test("hosted browse rejects foreign origins, invalid bounds, and oversized paths before dispatch", async () => { + const { request, calls } = hosted(); + assert.equal((await request("/query/api/browse", { store: "field-notes" }, "https://foreign.example")).status, 403); + assert.equal((await request("/query/api/browse", { store: "field-notes", asOf: "not-a-date" })).status, 400); + assert.equal((await request("/query/api/preview", { store: "field-notes", path: "x".repeat(4097) })).status, 400); + assert.equal((await request("/query/api/browse", { store: "unknown" })).status, 400); + assert.deepEqual(calls, []); +}); diff --git a/apps/console/test/live_browse.test.ts b/apps/console/test/live_browse.test.ts new file mode 100644 index 000000000..392624bd7 --- /dev/null +++ b/apps/console/test/live_browse.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createLiveBrowse } from "../src/live_browse.ts"; +import type { StoreEntry } from "../../gateway/src/index.ts"; + +const store: StoreEntry = { + id: "field-notes", label: "Field notes", endpoint: "https://store.example", + auth: "exchange", exchangeRoute: "/auth/exchange", credentialName: "FIELD_NOTES_QUERY_TOKEN", bindingName: "FIELD_NOTES", +}; + +test("live browse uses one exchanged reader credential for governed discovery and preview", async () => { + const calls: Array<{ name: string; args: Record; authorization: string | null }> = []; + let exchanges = 0; + const fetcher: typeof fetch = async (input, init) => { + const url = String(input); + if (url.endsWith("/auth/exchange")) { exchanges++; return Response.json({ token: "reader-token" }); } + assert.equal(url, "https://store.example/mcp"); + const body = JSON.parse(String(init?.body)) as { id: number; params: { name: string; arguments: Record } }; + calls.push({ name: body.params.name, args: body.params.arguments, authorization: new Headers(init?.headers).get("authorization") }); + const value = body.params.name === "context.describe" && !body.params.arguments.table ? { tables: [ + { table: "filings", kind: "data", zone_admitted: true, description: "Filed reports" }, + { table: "payroll", kind: "data", zone_admitted: false }, + { table: "insights", kind: "memory", zone_admitted: true }, + ] } : body.params.name === "context.describe" ? { row_count: 3 } : body.params.name === "context.files" ? { + columns: ["table", "path"], rows: [["filings", "filings/runs/run-1/report.parquet"], ["payroll", "payroll/runs/run-1/secret.parquet"]], + } : body.params.name === "context.file" ? { columns: ["filing_id"], rows: [["filing-1"]] } : null; + assert(value); + return Response.json({ jsonrpc: "2.0", id: body.id, result: { structuredContent: value } }); + }; + const browse = createLiveBrowse({ stores: [store], env: {}, fetcher }); + const input = { operator: { subject: "reader", grants: new Set(["query" as const]), assertion: "verified-jwt" }, store: store.id, + asOf: "2030-01-01T00:00:00Z" }; + const discovered = await browse.discover(input); + assert.deepEqual(discovered.chips.map((chip: { table: string }) => chip.table), ["filings"]); + assert.deepEqual(discovered.insights.map((insight: { rows: number }) => insight.rows), [3]); + assert.deepEqual(discovered.files.map((file: { path: string }) => file.path), ["filings/runs/run-1/report.parquet"]); + assert.equal(exchanges, 1); + assert(calls.every((call) => call.authorization === "Bearer reader-token")); + assert(calls.every((call) => call.args.as_of === input.asOf)); + const preview = await browse.preview({ ...input, path: "filings/runs/run-1/report.parquet" }); + assert.deepEqual(preview, { columns: ["filing_id"], rows: [["filing-1"]] }); + assert.equal(exchanges, 2); + assert.equal(calls.at(-1)?.name, "context.file"); + const before = calls.length; + await assert.rejects(browse.preview({ ...input, path: "filings/runs/run-1/other.parquet" }), /ConsoleGalleryPathUnlisted/); + assert.equal(calls.filter((call) => call.name === "context.file").length, 1); + assert(calls.length > before, "the refusal rechecks the governed file list"); +}); From 0472b942a03f048b97cac87212e42cdeacc42150 Mon Sep 17 00:00:00 2001 From: debuggingfuture Date: Tue, 6 Oct 2026 17:19:01 +0800 Subject: [PATCH 05/10] feat(console): host governed browse in Query --- apps/console/src/browse.ts | 14 ++++++---- apps/console/src/entry.ts | 7 +++-- apps/console/src/index.ts | 40 ++++++++++++++++++++++++--- apps/console/src/live.ts | 18 ++++++++---- apps/console/src/live_browse.ts | 21 ++++++++++++++ apps/console/test/browse.test.ts | 2 +- apps/console/test/live_browse.test.ts | 4 ++- 7 files changed, 87 insertions(+), 19 deletions(-) create mode 100644 apps/console/src/live_browse.ts diff --git a/apps/console/src/browse.ts b/apps/console/src/browse.ts index 135da2332..7da26ed58 100644 --- a/apps/console/src/browse.ts +++ b/apps/console/src/browse.ts @@ -10,6 +10,9 @@ type ReadTransport = { type TableEntry = { table: string; description?: string; zone_admitted?: boolean }; type FileEntry = { table: string; path: string; label: string }; type BrowseRequest = { asOf?: string }; +const MAX_TABLES = 64; +const MAX_FILES = 128; +const MAX_PREVIEW_ROWS = 100; export class BrowseError extends Error { readonly code: string; @@ -56,14 +59,14 @@ export function createBrowse(transport: ReadTransport) { async function listed(request: BrowseRequest): Promise<{ tables: TableEntry[]; files: FileEntry[] }> { const listing = await read("context.describe", bounds(request)); if (!Array.isArray(listing.tables)) throw new BrowseError("ConsoleBrowseResponseMalformed"); - const tables = listing.tables.map((item) => object(item)).filter((item) => item.zone_admitted === true && typeof item.table === "string") - .map((item) => ({ table: item.table as string, description: typeof item.description === "string" ? item.description : undefined })); + const tables = listing.tables.map((item) => object(item)).filter((item) => item.zone_admitted === true && item.kind !== "memory" && typeof item.table === "string") + .slice(0, MAX_TABLES).map((item) => ({ table: item.table as string, description: typeof item.description === "string" ? item.description : undefined })); const names = new Set(tables.map((table) => table.table)); const fileListing = await read("context.files", bounds(request)); if (!Array.isArray(fileListing.rows)) throw new BrowseError("ConsoleBrowseResponseMalformed"); const files = fileListing.rows.filter((row): row is [string, string] => Array.isArray(row) && typeof row[0] === "string" && typeof row[1] === "string" && names.has(row[0])) - .map(([table, path]) => ({ table, path, label: humanizeLabel(path) })); + .slice(0, MAX_FILES).map(([table, path]) => ({ table, path, label: humanizeLabel(path) })); return { tables, files }; } @@ -83,9 +86,10 @@ export function createBrowse(transport: ReadTransport) { async preview(request: BrowseRequest & { path: string }) { const { files } = await listed(request); if (!files.some((file) => file.path === request.path)) throw new BrowseError("ConsoleGalleryPathUnlisted", request.path); - const response = await read("context.file", { path: request.path, ...bounds(request) }); + const response = await read("context.file", { path: request.path, limit: MAX_PREVIEW_ROWS, ...bounds(request) }); if (!Array.isArray(response.columns) || !Array.isArray(response.rows)) throw new BrowseError("ConsoleBrowseResponseMalformed"); - return { columns: response.columns, rows: response.rows }; + return { columns: response.columns.slice(0, 64), rows: response.rows.slice(0, MAX_PREVIEW_ROWS).map((row) => + Array.isArray(row) ? row.slice(0, 64) : []) }; }, }; } diff --git a/apps/console/src/entry.ts b/apps/console/src/entry.ts index a86974d02..e5a0d39c3 100644 --- a/apps/console/src/entry.ts +++ b/apps/console/src/entry.ts @@ -3,10 +3,11 @@ import { pathToFileURL } from "node:url"; import { isAbsolute, resolve } from "node:path"; import { registryFromEnv } from "../../gateway/src/index.ts"; import { createLiveTurn } from "./live.ts"; +import { createLiveBrowse } from "./live_browse.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"] }>; +type AdapterFactory = (input: { stores: ConsoleAdapters["stores"]; env: NodeJS.ProcessEnv }) => Promise & { read?: ConsoleAdapters["read"]; browse?: ConsoleAdapters["browse"] }>; function required(name: string): string { const value = process.env[name]; @@ -82,9 +83,10 @@ async function main(): Promise { const registry = registryFromEnv(process.env.CONTEXTFUL_STORES_JSON); const stores = registry.entries.map(({ id, label }) => ({ id, label })); const modulePath = process.env.CONTEXTFUL_CONSOLE_ADAPTER_MODULE; - let adapters: Pick & { read?: ConsoleAdapters["read"] } = { + let adapters: Pick & { read?: ConsoleAdapters["read"]; browse?: ConsoleAdapters["browse"] } = { ...unavailableAdapters(), turn: modulePath ? unavailableAdapters().turn : createLiveTurn({ stores: registry.entries, env: process.env }), + browse: modulePath ? undefined : createLiveBrowse({ stores: registry.entries, env: process.env }), }; if (modulePath) { const absolute = isAbsolute(modulePath) ? modulePath : resolve(modulePath); @@ -99,6 +101,7 @@ async function main(): Promise { adminCapability: process.env.CONTEXTFUL_ADMIN_CAPABILITY, turn: adapters.turn, read: adapters.read ?? { list: async () => stores }, + browse: adapters.browse, control: adapters.control, }, () => origin); await new Promise((resolveListen, rejectListen) => { diff --git a/apps/console/src/index.ts b/apps/console/src/index.ts index 8691579fc..b5175bae8 100644 --- a/apps/console/src/index.ts +++ b/apps/console/src/index.ts @@ -1,5 +1,7 @@ import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify, type KeyObject } from "node:crypto"; import { ConsoleError } from "./turn.ts"; +import { parseVantage } from "./turn.ts"; +import { BrowseError } from "./browse.ts"; export type Operator = { subject: string; grants: ReadonlySet<"query" | "admin">; assertion?: string }; export type Store = { id: string; label: string }; @@ -35,6 +37,10 @@ export type ConsoleAdapters = { adminCapability?: string; turn: (input: TurnInput) => Promise; read: { list: (operator: Operator) => Promise }; + browse?: { + discover: (input: { operator: Operator; store: string; asOf?: string }) => Promise; + preview: (input: { operator: Operator; store: string; asOf?: string; path: string }) => Promise; + }; control: { workflows: (operator: Operator, store: string | null) => Promise; record: (operator: Operator, store: string | null) => Promise; @@ -261,6 +267,28 @@ export function createConsole(adapters: ConsoleAdapters): { fetch: (request: Req 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/browse" || path === "/query/api/preview")) { + let input: unknown; + try { input = await body(request); } catch (error) { return bodyFailure(error); } + if (!object(input) || typeof input.store !== "string" || !adapters.stores.some((store) => store.id === input.store) || + (input.asOf !== undefined && typeof input.asOf !== "string") || + (path.endsWith("/preview") && (typeof input.path !== "string" || !input.path || input.path.length > 4096))) { + return refusal("ConsoleRequestMalformed", 400); + } + let asOf: string | undefined; + try { asOf = input.asOf === undefined ? undefined : parseVantage(input.asOf as string); } + catch { return refusal("ConsoleVantageUnparseable", 400); } + if (!adapters.browse) return refusal("ConsoleAdapterUnavailable", 503); + try { + return json(path.endsWith("/preview") ? + await adapters.browse.preview({ operator, store: input.store, asOf, path: input.path as string }) : + await adapters.browse.discover({ operator, store: input.store, asOf })); + } catch (error) { + if (error instanceof BrowseError) return refusal(error.code, error.code === "ConsoleGalleryPathUnlisted" ? 403 : 400); + if (error instanceof ConsoleError) return refusal(error.code, error.status); + throw error; + } + } if (request.method === "POST" && path === "/query/api/ask") { let input: unknown; try { input = await body(request); } catch (error) { return bodyFailure(error); } @@ -303,11 +331,15 @@ 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