diff --git a/docs/deck/reader-browser-results.json b/docs/deck/reader-browser-results.json new file mode 100644 index 0000000..a16baac --- /dev/null +++ b/docs/deck/reader-browser-results.json @@ -0,0 +1,40 @@ +{ + "scope": "Actual browser Fetch/CORS, streamed body limits and Deck DOM; all responses intercepted synthetic fixtures, no native or human proof", + "results": [ + { + "mode": "answered", + "status": "Answered \u00b7 fixture-operator \u00b7 2030-03-17T17:46:41.000Z", + "synthetic_http_calls": 11, + "page_errors": [], + "unhandled_routes": [] + }, + { + "mode": "empty", + "status": "Current request \u00b7 no admitted decision.", + "synthetic_http_calls": 8, + "page_errors": [], + "unhandled_routes": [] + }, + { + "mode": "changed-query", + "status": "UNKNOWN \u00b7 request or verified answer evidence unavailable.", + "synthetic_http_calls": 6, + "page_errors": [], + "unhandled_routes": [] + }, + { + "mode": "missing-receipt", + "status": "UNKNOWN \u00b7 request or verified answer evidence unavailable.", + "synthetic_http_calls": 7, + "page_errors": [], + "unhandled_routes": [] + }, + { + "mode": "oversized", + "status": "UNKNOWN \u00b7 request or verified answer evidence unavailable.", + "synthetic_http_calls": 1, + "page_errors": [], + "unhandled_routes": [] + } + ] +} diff --git a/docs/deck/reader-native-results.json b/docs/deck/reader-native-results.json new file mode 100644 index 0000000..be16efa --- /dev/null +++ b/docs/deck/reader-native-results.json @@ -0,0 +1,111 @@ +{ + "candidate": "quipu 0.16.3\ngit_sha: 771e710de7294b718ceaa1a4b825fd28e4b34c23\ngit_dirty: false", + "native_library_sha256": "2ac7b3096f54903dd18664016fc7d102632cb8b079a4fbb239b8818b9d3e1ef8", + "helper_source_sha256": "3bcfd058028c532c711fb02dd3e6743ddd48484ed7813db6c24e5e82e65b2ce3", + "fixture": "Disposable stores; generated software keys kept only in memory; local native methods and actual operator CLI; no HTTP/service/real credential or production profile union", + "results": [ + { + "case": "choose", + "state": "ANSWERED", + "canonical_native_match": true, + "native_verified": true, + "replay_no_mutation": true + }, + { + "case": "choose-stored-reader", + "native_registered_queries": true, + "graph_creation_bound": true, + "typed_facts_and_provenance": true + }, + { + "case": "later", + "state": "LATER", + "canonical_native_match": true, + "native_verified": true, + "replay_no_mutation": true + }, + { + "case": "later-stored-reader", + "native_registered_queries": true, + "graph_creation_bound": true, + "typed_facts_and_provenance": true + }, + { + "case": "comment", + "state": "WAITING", + "canonical_native_match": true, + "native_verified": true, + "replay_no_mutation": true + }, + { + "case": "comment-stored-reader", + "native_registered_queries": true, + "graph_creation_bound": true, + "typed_facts_and_provenance": true + }, + { + "case": "forged", + "refused_no_mutation": true, + "nonce_and_counter_unspent": true + }, + { + "case": "wrong-origin", + "refused_no_mutation": true, + "nonce_and_counter_unspent": true + }, + { + "case": "item-revision", + "refused_no_mutation": true, + "nonce_and_counter_unspent": true + }, + { + "case": "closed-item", + "refused_no_mutation": true, + "nonce_and_counter_unspent": true + }, + { + "case": "request-revision", + "refused_no_mutation": true, + "nonce_and_counter_unspent": true + }, + { + "case": "missing-expectation-0", + "native_verified": true, + "deck_reader_refused": true + }, + { + "case": "missing-expectation-1", + "native_verified": true, + "deck_reader_refused": true + }, + { + "case": "missing-expectation-2", + "native_verified": true, + "deck_reader_refused": true + }, + { + "case": "missing-expectation-3", + "native_verified": true, + "deck_reader_refused": true + }, + { + "case": "missing-expectation-4", + "native_verified": true, + "deck_reader_refused": true + }, + { + "case": "missing-comment-floor", + "native_verified": true, + "deck_reader_refused": true, + "limitation": "Native post-state admission excludes MinCount floors; full shape validation and client/reader constraints remain distinct" + }, + { + "case": "loaded-action-value-constraint", + "refused_no_mutation": true + }, + { + "case": "ordinary-graph-and-other-request-revision", + "deck_reader_refused": true + } + ] +} diff --git a/docs/deck/reader.md b/docs/deck/reader.md new file mode 100644 index 0000000..55acbb8 --- /dev/null +++ b/docs/deck/reader.md @@ -0,0 +1,75 @@ +# Bounded online reader source + +`web/deck/reader.mjs` supplies the read function accepted by the online Deck +view. Importing it performs no request. The shipped app still has signing +disabled and selects no production endpoint, registration receipt or query. + +`readerDefinitions(boundary, provenanceNamespace)` constructs five stored-query +definitions: request, item, answers, facts and subject-to-receipt links. They +use explicit graph IRIs and validated IRI/integer parameters. The native +provenance namespace must equal the primitive's fixed vocabulary identifier; +a configurable lookalike could otherwise hide real answers as an empty set. +It is not the Rikuy vocabulary. The adapter sends named `/ask` requests, never raw `/query` text. +The exact rendered text returned by `/ask` must match the source definition. + +Before a deployment can use the adapter, its owner must register those exact +definitions through the normal governed path and admit their served plans and +read cost. Each configured query hash is SHA-256 of the UTF-8 JSON encoding of +the source definition. It binds name, template, parameter kinds and output +bound. A hash supplied by a caller is not a registration or read-cost receipt. +This source neither registers queries nor grants permission to run them. + +The reader also requires retained creation provenance for all three committed +graphs. It checks each through the bounded `GET /graphs?graph=` route. +An absent, replaced, overlay or ambiguous graph is UNKNOWN. This metadata +check does not prove graph protection, origin admission or operator enablement. +Native verified point receipts and exact typed fact/provenance read-back are +still required for every answer. + +Output limits deliberately include an overflow sentinel. Request and item +queries stop at three rows and require exactly one; answer queries stop at 21 +and admit at most 20; fact and link queries stop at 65 and admit at most 64. +Reaching the limit, truncation, duplicate rows, count mismatch, changed query, +missing receipt or incomplete read-back refuses the entire view. An empty +answer list needs the positive request/item reads and all graph bindings. +No missing evidence is converted to a settled answer. + +One cycle has a maximum six-second deadline, no overlapping cycle and at most +two requests in flight. An injected transport must honor abort and streaming +byte limits. `readerTransport` provides a browser Fetch implementation with +those byte limits, redirect refusal, omitted credentials, no cache and the +`daily-brief` client attribution. It permits only the pinned HTTPS origin's +graph, named-query and point-receipt routes; it has no commit or registration +method. It neither signs nor retries a write. + +Request and item reads bracket answer retrieval. A changed revision or closed +item refuses the result. These separate reads do not establish an atomic +snapshot or rule out every intervening change. The signed proposal's native +CAS remains authoritative at commit time. + +`just ci` covers complete positive reads, positive controls for empty answers, +graph and query identity, overflow, missing/foreign receipts, wrong fact types, +changed/closed items, request/item bookends, deadlines, overlapping cycles, +streamed byte limits and restricted HTTP routes. The native qualification +command in [contract.md](contract.md) now loads all five definitions into +disposable stores and uses actual stored-query execution, graph metadata, +point receipts and typed read-back for Choose, Later and comment. + +The native transport dispatch in that qualification is synthetic and local. +It proves neither served HTTP/CORS nor production query cost, profile-union +shape admission, graph protection or human/device enrollment. The result +archive in [reader-native-results.json](reader-native-results.json) records +the candidate and fixture hashes. No production adapter is selected by this +change, and there is no newly observable behavior in the disabled page. + +The browser qualification in `scripts/qualify-deck-reader-browser.cjs` mounts +the actual view with this reader and uses actual browser Fetch/CORS and body +streaming. Every HTTP reply is intercepted synthetic data. Its five controls +show Answered, empty-but-controlled Waiting, changed query UNKNOWN, missing +receipt UNKNOWN and oversized reply UNKNOWN. The archive is +[reader-browser-results.json](reader-browser-results.json). Run it with an +explicit local Playwright installation and Chrome: + +```sh +PLAYWRIGHT_MODULE=/path/to/playwright node scripts/qualify-deck-reader-browser.cjs +``` diff --git a/scripts/qualify-deck-native.mjs b/scripts/qualify-deck-native.mjs index cd034f4..3d24c9a 100644 --- a/scripts/qualify-deck-native.mjs +++ b/scripts/qualify-deck-native.mjs @@ -8,6 +8,7 @@ import assert from 'node:assert/strict'; import {NS} from '../web/deck/value.mjs'; import {buildProposal, canonicalProposal, operationIri, requestPublication, readRequest, revisionIri} from '../web/deck/model.mjs'; import {admittedChallenge, readAnswer, disposition, readbackProof} from '../web/deck/evidence.mjs'; +import {DeckReader, readerDefinitions} from '../web/deck/reader.mjs'; const cli = process.env.QUIPU_CLI, artifacts = process.env.QUIPU_ARTIFACTS; if (!cli || !artifacts) throw Error('Explicit candidate QUIPU_CLI and fresh QUIPU_ARTIFACTS JSONL required'); const root = mkdtempSync(join(tmpdir(), 'rikuy-native-')), source = resolve(import.meta.dirname, '..'); @@ -98,6 +99,24 @@ try { assert.deepEqual(footprint(state, e.body.nonce), before); assert.throws(() => command(state.db, 'challenge', state.p)); // Decided or operation slot is spent. results.push({case: kind, state: disposition([answer], state.r, boundary).state, canonical_native_match: true, native_verified: e.receipt.verified, replay_no_mutation: true}); + const nativeNamespace = e.facts.find(d => d.p.endsWith('attestedBy')).p.slice(0, -'attestedBy'.length); + const definitions = readerDefinitions(boundary, nativeNamespace); + const queryHashes = Object.fromEntries(Object.entries(definitions).map(([role, def]) => [role, sha(Buffer.from(JSON.stringify(def)))])); + for (const def of Object.values(definitions)) command(state.db, 'query-load', def); + const registrations = [boundary.requestGraph, boundary.answerGraph, boundary.itemGraph].map(graph => { + const metadata = command(state.db, 'graphs', {graph}).graphs[0]; + return {iri: graph, creation: metadata.creation}; + }); + // Real stored-query rendering/execution, native point receipts and registry; + // synthetic transport dispatch only, with no HTTP or production endpoint. + const reader = new DeckReader({endpoint: 'https://reader.example.test/', boundary, operator: state.who, provenanceNamespace: nativeNamespace, queryHashes, registrations, request: async (address, {body}) => { + const url = new URL(address); + return command(state.db, url.pathname === '/graphs' ? 'graphs' : url.pathname === '/ask' ? 'ask' : 'receipt', url.pathname === '/graphs' ? {graph: url.searchParams.get('graph')} : body); + }}); + const observed = await reader.read(state.r.id); + assert.equal(observed.rows.length, 1); + assert.equal(observed.rows[0].receipt.receipt_iri, e.receipt.receipt_iri); + results.push({case: `${kind}-stored-reader`, native_registered_queries: true, graph_creation_bound: true, typed_facts_and_provenance: true}); } for (const [index, kind] of ['forged', 'wrong-origin', 'item-revision', 'closed-item', 'request-revision'].entries()) { const state = setup(index + 10), issued = command(state.db, 'challenge', state.p); diff --git a/scripts/qualify-deck-reader-browser.cjs b/scripts/qualify-deck-reader-browser.cjs new file mode 100644 index 0000000..da01688 --- /dev/null +++ b/scripts/qualify-deck-reader-browser.cjs @@ -0,0 +1,48 @@ +// # arming: agent-invoked synthetic browser qualification; no production traffic. +if (!process.env.PLAYWRIGHT_MODULE) throw Error('Explicit PLAYWRIGHT_MODULE required'); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE); +const fs=require('fs'),path=require('path'),assert=require('assert/strict'); +const root=path.resolve(__dirname,'..'); +(async()=>{ + const browser=await chromium.launch({executablePath:'/usr/bin/google-chrome',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});const results=[]; + try{for(const mode of ['answered','empty','changed-query','missing-receipt','oversized']){ + const context=await browser.newContext(),page=await context.newPage(),errors=[],blocked=[];let calls=0; + page.on('pageerror',e=>errors.push(e.message)); + await context.route('**/*',async route=>{ + const req=route.request(),url=new URL(req.url()); + if(url.origin==='https://sign.example.test'){ + if(url.pathname==='/'){await route.fulfill({contentType:'text/html',body:'
'});return;} + if(/^\/(web\/deck\/[a-z-]+|tests\/deck-fixture)\.mjs$/.test(url.pathname)){await route.fulfill({contentType:'text/javascript',body:fs.readFileSync(path.join(root,url.pathname))});return;} + } + if(url.origin==='https://reader.example.test'&&['/graphs','/ask','/attest/receipt'].includes(url.pathname)){ + const headers={'access-control-allow-origin':'https://sign.example.test','access-control-allow-headers':'content-type,x-quipu-client','access-control-allow-methods':'GET,POST,OPTIONS'}; + if(req.method()==='OPTIONS'){await route.fulfill({status:204,headers});return;} + calls++; + const value=await page.evaluate(async ({path,graph,body,mode})=>{ + const f=await import('/tests/deck-fixture.mjs'),m=await import('/web/deck/model.mjs'),r=await import('/web/deck/reader.mjs'); + const definitions=r.readerDefinitions(f.boundary,r.NATIVE_PROVENANCE_NS),e=await f.evidence(); + if(path==='/graphs')return{count:1,graphs:[{iri:graph,g:1,class:'committed',creation:{actor:'fixture-curator',timestamp:'2026-10-25T00:00:00Z'}}]}; + if(path==='/attest/receipt')return mode==='missing-receipt'?{found:false}:e.response; + const role=Object.keys(definitions).find(k=>definitions[k].name===body.name),def=definitions[role]; + const rows={request:[{revision:f.ask.revision,revisionIri:m.revisionIri(f.ask),manifest:m.requestPublication(f.ask,f.boundary).datums[1].o.value}],item:[{revision:f.ask.itemRevision,status:f.boundary.openStatus}],answers:mode==='empty'?[]:[{subject:e.row.subject,receiptIri:e.row.receiptIri,manifest:e.row.manifest}],facts:e.readback.datums.map(d=>({s:d.s,p:d.p,kind:d.o.kind,value:String(d.o.value)})),links:e.readback.attestedBy.map(({s})=>({s}))}[role]; + return{query:def.name,sparql:r.renderReaderQuery(def,body.params)+(mode==='changed-query'&&role==='answers'?' ':''),rows,count:rows.length,truncated:false}; + },{path:url.pathname,graph:url.searchParams.get('graph'),body:req.method()==='POST'?req.postDataJSON():null,mode}); + await route.fulfill({headers,contentType:'application/json',body:mode==='oversized'?' '.repeat(262145):JSON.stringify(value)});return; + } + blocked.push(url.href);await route.abort(); + }); + await page.goto('https://sign.example.test/'); + await page.evaluate(async()=>{ + const f=await import('/tests/deck-fixture.mjs'),r=await import('/web/deck/reader.mjs'),v=await import('/web/deck/value.mjs'),{mount}=await import('/web/deck/view.mjs'); + const definitions=r.readerDefinitions(f.boundary,r.NATIVE_PROVENANCE_NS),queryHashes=Object.fromEntries(await Promise.all(Object.entries(definitions).map(async([k,d])=>[k,await v.sha256(v.utf8(JSON.stringify(d)))]))); + const reader=new r.DeckReader({endpoint:'https://reader.example.test/',boundary:f.boundary,operator:f.who,provenanceNamespace:r.NATIVE_PROVENANCE_NS,queryHashes,registrations:[f.boundary.requestGraph,f.boundary.answerGraph,f.boundary.itemGraph].map(iri=>({iri,creation:{actor:'fixture-curator',timestamp:'2026-10-25T00:00:00Z'}})),request:r.readerTransport('https://reader.example.test/')}); + await mount(document.querySelector('main'),{enabled:true,boundary:f.boundary,identity:f.who,read:()=>reader.read(f.ask.id),writer:{identity:f.who,store:{get:async()=>null}},now:()=>1900000000}); + }); + const status=await page.locator('[role=status]').textContent(); + assert(mode==='answered'?status.startsWith('Answered'):mode==='empty'?status.startsWith('Current request'):status.startsWith('UNKNOWN'),status); + if(!['answered','empty'].includes(mode))assert.equal(await page.locator('button').count(),0); + assert.deepEqual(errors,[]);assert.deepEqual(blocked,[]);assert(calls>0&&calls<=13); + results.push({mode,status,synthetic_http_calls:calls,page_errors:errors,unhandled_routes:blocked});await context.close(); + }}finally{await browser.close();} + console.log(JSON.stringify({scope:'Actual browser Fetch/CORS, streamed body limits and Deck DOM; all responses intercepted synthetic fixtures, no native or human proof',results},null,2)); +})().catch(e=>{console.error(e.stack);process.exitCode=1;}); diff --git a/tests/deck-reader.test.mjs b/tests/deck-reader.test.mjs new file mode 100644 index 0000000..fa4317c --- /dev/null +++ b/tests/deck-reader.test.mjs @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {DeckReader, readerDefinitions, renderReaderQuery, readerTransport, NATIVE_PROVENANCE_NS} from '../web/deck/reader.mjs'; +import {requestPublication, revisionIri} from '../web/deck/model.mjs'; +import {sha256, utf8} from '../web/deck/value.mjs'; +import {ask, boundary, who, evidence} from './deck-fixture.mjs'; + +const provenance = NATIVE_PROVENANCE_NS; +async function fixture({change = value => value, deadlineMs = 6000} = {}) { + const definitions = readerDefinitions(boundary, provenance), ev = await evidence(); + const queryHashes = Object.fromEntries(await Promise.all(Object.entries(definitions).map(async ([k, v]) => [k, await sha256(utf8(JSON.stringify(v)))]))); + const registrations = [boundary.requestGraph, boundary.answerGraph, boundary.itemGraph].map((iri, i) => ({iri, creation: {actor: 'fixture-curator', timestamp: '2026-10-25T00:00:00Z'}, g: i + 1})); + let active = 0, peak = 0; + const calls = []; + const request = async (address, options) => { + const url = new URL(address); calls.push({path: url.pathname, body: options.body}); active++; peak = Math.max(active, peak); + try { + await new Promise(resolve => setImmediate(resolve)); + let role, result; + if (url.pathname === '/graphs') { const registration = registrations.find(r => r.iri === url.searchParams.get('graph')); result = {count: 1, graphs: [{...registration, class: 'committed'}]}; } + else if (url.pathname === '/attest/receipt') result = structuredClone(ev.response); + else { + assert.equal(url.pathname, '/ask'); + role = Object.keys(definitions).find(k => definitions[k].name === options.body.name); + const rows = { + request: [{revision: ask.revision, revisionIri: revisionIri(ask), manifest: requestPublication(ask, boundary).datums[1].o.value}], + item: [{revision: ask.itemRevision, status: boundary.openStatus}], + answers: [{subject: ev.row.subject, receiptIri: ev.row.receiptIri, manifest: ev.row.manifest}], + facts: ev.readback.datums.map(d => ({s: d.s, p: d.p, kind: d.o.kind, value: String(d.o.value)})), + links: ev.readback.attestedBy.map(({s}) => ({s})) + }[role]; + result = {query: definitions[role].name, sparql: renderReaderQuery(definitions[role], options.body.params), rows, count: rows.length, truncated: false}; + } + return await change(result, {role, path: url.pathname, options, calls}); + } finally {active--;} + }; + const options = {endpoint: 'https://reader.example.test/', boundary, operator: who, provenanceNamespace: provenance, queryHashes, registrations, request, deadlineMs}; + return {reader: new DeckReader(options), options, calls, peak: () => peak, ev}; +} + +test('bounded source adapter reads exact requests, native receipts and complete typed facts', async () => { + const f = await fixture(), result = await f.reader.read(ask.id); + assert.equal(result.complete, true); assert.equal(result.rows.length, 1); + assert.equal(result.rows[0].receipt.receipt_iri, f.ev.row.receiptIri); + assert.equal(result.requestRow.currentRevision, ask.revision); + assert.equal(f.peak(), 2); + assert.equal(f.calls.filter(c => c.path === '/attest/receipt').length, 1); + assert(f.calls.every(c => ['/graphs', '/ask', '/attest/receipt'].includes(c.path))); +}); + +test('empty answer population needs positive request/item controls and graph bindings', async () => { + const f = await fixture({change: (v, {role}) => role === 'answers' ? {...v, rows: [], count: 0} : v}); + assert.deepEqual((await f.reader.read(ask.id)).rows, []); + for (const role of ['request', 'item']) { + const bad = await fixture({change: (v, ctx) => ctx.role === role ? {...v, rows: [], count: 0} : v}); + await assert.rejects(() => bad.reader.read(ask.id)); + } +}); + +test('changed query, truncation, count mismatch, duplicate and overflow refuse', async () => { + for (const mutate of [v => ({...v, query: 'another_query'}), v => ({...v, sparql: v.sparql + ' '}), v => ({...v, truncated: true}), v => ({...v, truncated: undefined}), v => ({...v, count: 99}), v => ({...v, rows: [v.rows[0], v.rows[0]], count: 2}), v => ({...v, rows: Array.from({length: 21}, (_, i) => ({...v.rows[0], subject: `urn:overflow:${i}`})), count: 21})]) { + const f = await fixture({change: (v, {role}) => role === 'answers' ? mutate(v) : v}); + await assert.rejects(() => f.reader.read(ask.id)); + } +}); + +test('graph creation/class and pinned definition hashes must match', async () => { + for (const mutate of [g => ({...g, iri: boundary.itemGraph}), g => ({...g, class: 'overlay'}), g => ({...g, g: 0}), g => ({...g, creation: null})]) { + const f = await fixture({change: (v, {path}) => path === '/graphs' ? {...v, graphs: [mutate(v.graphs[0])]} : v}); + await assert.rejects(() => f.reader.read(ask.id)); + assert.equal(f.calls.some(c => c.path === '/ask'), false); + } + const f = await fixture(); f.reader.queryHashes.answers = '0'.repeat(64); + await assert.rejects(() => f.reader.read(ask.id)); assert.equal(f.calls.length, 0); +}); + +test('missing/foreign/unverified receipt and incomplete facts or links refuse', async () => { + const mutations = [ + (v, c) => c.path === '/attest/receipt' ? {...v, found: false} : v, + (v, c) => c.path === '/attest/receipt' ? {...v, verified: false} : v, + (v, c) => c.path === '/attest/receipt' ? {...v, receipt: {...v.receipt, challenge: {...v.receipt.challenge, audience: 'wrong-store'}}} : v, + (v, c) => c.role === 'facts' || c.role === 'links' ? {...v, rows: v.rows.slice(1), count: v.rows.length - 1} : v, + (v, c) => c.role === 'facts' ? {...v, rows: v.rows.map(r => r.kind === 'int' ? {...r, kind: 'str'} : r)} : v, + (v, c) => c.role === 'facts' ? {...v, rows: v.rows.map(r => r.kind === 'int' ? {...r, value: '9007199254740992'} : r)} : v + ]; + for (const change of mutations) {const f = await fixture({change}); await assert.rejects(() => f.reader.read(ask.id));} +}); + +test('changed/closed or ambiguous item refuses before receipt calls', async () => { + for (const row of [{revision: ask.itemRevision + 1, status: boundary.openStatus}, {revision: ask.itemRevision, status: 'urn:closed'}, {revision: String(ask.itemRevision), status: boundary.openStatus}]) { + const f = await fixture({change: (v, {role}) => role === 'item' ? {...v, rows: [row]} : v}); + await assert.rejects(() => f.reader.read(ask.id)); + assert.equal(f.calls.some(c => c.path === '/attest/receipt'), false); + } +}); + +test('request bookend refuses a changed revision even after valid answer read-back', async () => { + let count = 0; + const f = await fixture({change: (v, {role}) => role === 'request' && ++count === 2 ? {...v, rows: [] , count: 0} : v}); + await assert.rejects(() => f.reader.read(ask.id)); + assert.equal(f.calls.filter(c => c.body?.name === 'rikuy_deck_request_v1').length, 2); +}); + +test('item bookend refuses closure during an otherwise valid answer read', async () => { + let count = 0; + const f = await fixture({change: (v, {role}) => role === 'item' && ++count === 2 ? {...v, rows: [{revision: ask.itemRevision, status: 'urn:closed'}]} : v}); + await assert.rejects(() => f.reader.read(ask.id), /closed/); + assert.equal(f.calls.filter(c => c.body?.name === 'rikuy_deck_item_v1').length, 2); +}); + +test('one overall deadline bounds even a transport that fails to settle', async () => { + const f = await fixture({deadlineMs: 20, change: () => new Promise(() => {})}); + await assert.rejects(() => f.reader.read(ask.id), /deadline/); + assert.equal(f.reader.busy, false); +}); + +test('overlapping cycles refuse and no default or insecure endpoint is selected', async () => { + const f = await fixture(); const running = f.reader.read(ask.id); + await assert.rejects(() => f.reader.read(ask.id), /already active/); await running; + for (const endpoint of ['http://reader.example.test/', 'https://reader.example.test/path', 'https://name:secret@reader.example.test/']) assert.throws(() => new DeckReader({...f.options, endpoint})); + assert.throws(() => renderReaderQuery(readerDefinitions(boundary, provenance).request, {requestId: 'https://example.test/> SELECT'})); + assert.throws(() => readerDefinitions(boundary, 'https://example.test/lookalike/'), /vocabulary/); +}); + +test('streaming transport caps bytes and restricts origin, methods, redirects and credentials', async () => { + const calls = [], ctl = new AbortController(); + const transport = readerTransport('https://reader.example.test/', async (url, options) => {calls.push({url, options}); return new Response('{"ok":true}', {headers: {'content-type': 'application/json'}});}); + assert.deepEqual(await transport('https://reader.example.test/ask', {body: {name: 'fixture'}, signal: ctl.signal, cap: 100}), {ok: true}); + assert.equal(calls[0].options.method, 'POST'); assert.equal(calls[0].options.credentials, 'omit'); assert.equal(calls[0].options.redirect, 'error'); + await assert.rejects(() => transport('https://reader.example.test/ask', {body: {}, signal: ctl.signal, cap: 2}), /byte bound/); + for (const url of ['https://other.example.test/ask', 'https://reader.example.test/episode', 'https://reader.example.test/attest/commit', 'https://reader.example.test/ask?raw=query']) await assert.rejects(() => transport(url, {body: {}, signal: ctl.signal, cap: 100})); + for (const response of [new Response('denied', {status: 403}), new Response('{}', {headers: {'content-type': 'text/html'}}), new Response(new Uint8Array([255]), {headers: {'content-type': 'application/json'}})]) { + await assert.rejects(() => readerTransport('https://reader.example.test/', async () => response)('https://reader.example.test/ask', {body: {}, signal: ctl.signal, cap: 100})); + } +}); diff --git a/tests/native/deck-fixture.rs b/tests/native/deck-fixture.rs index 3268448..2f69823 100644 --- a/tests/native/deck-fixture.rs +++ b/tests/native/deck-fixture.rs @@ -46,6 +46,38 @@ fn main() -> Result<(), Box> { "commit" => quipu::tool_attest_commit(&mut s, &v)?, "receipt" => quipu::tool_attest_receipt(&s, &v)?, "store-id" => json!({"store_id":s.store_id()?}), + "graphs" => quipu::tool_graph_list(&s, &v)?, + "ask" => quipu::tool_ask(&s, &v)?, + "query-load" => { + use quipu::store::queries::{StoredParam, StoredQuery}; + let params = v["params"] + .as_object() + .unwrap() + .iter() + .map(|(name, kind)| StoredParam { + name: name.clone(), + kind: match kind.as_str().unwrap() { + "iri" => "iri".into(), + "int" => "int".into(), + _ => panic!("fixture parameter kind"), + }, + required: true, + default: None, + description: "Synthetic reader qualification".into(), + }) + .collect(); + s.query_load( + &StoredQuery { + name: v["name"].as_str().unwrap().into(), + description: "Disposable Rikuy reader definition".into(), + template: v["template"].as_str().unwrap().into(), + dataset: None, + params, + }, + "2026-10-25T00:00:00Z", + )?; + json!({"loaded":true}) + } "facts" => { let g = s.lookup(v["graph"].as_str().unwrap())?.unwrap(); let mut rows = Vec::new(); diff --git a/web/deck/reader.mjs b/web/deck/reader.mjs new file mode 100644 index 0000000..d24b7e1 --- /dev/null +++ b/web/deck/reader.mjs @@ -0,0 +1,166 @@ +import {Unknown, NS, exact, iri, integer, text, utf8, sha256} from './value.mjs'; +import {scope, readRequest, decodeProposal} from './model.mjs'; +import {identity, readAnswer, receiptProof, readbackProof} from './evidence.mjs'; + +// Native provenance vocabulary identifier, fixed by the primitive's contract. +// A configurable lookalike would turn existing answers into a false empty set. +export const NATIVE_PROVENANCE_NS = 'http://aegis.gastown.local/ontology/'; + +// Source definitions only. An operator must separately register these exact +// templates and admit their cost on the served store. No registration writer. +export function readerDefinitions(boundary, provenanceNamespace) { + const b = scope(boundary), q = iri(provenanceNamespace); + if (q !== NATIVE_PROVENANCE_NS) throw new Unknown('Unknown native provenance vocabulary'); + const by = `<${q}attestedBy>`, graph = `<${b.answerGraph}>`; + const define = (name, template, params, cap) => ({name: `rikuy_deck_${name}_v1`, template, params, cap}); + return { + request: define('request', `SELECT ?revision ?revisionIri ?manifest WHERE { GRAPH <${b.requestGraph}> { <{requestId}> <${NS}currentRevision> ?revision . ?revisionIri <${NS}requestManifest> ?manifest . FILTER(?revisionIri = IRI(CONCAT(STR(<{requestId}>), "#revision-", STR(?revision)))) } } LIMIT 3`, {requestId: 'iri'}, 3), + item: define('item', `SELECT ?revision ?status WHERE { GRAPH <${b.itemGraph}> { <{seed}> <${b.revisionPredicate}> ?revision ; <${b.statusPredicate}> ?status } } LIMIT 3`, {seed: 'iri'}, 3), + answers: define('answers', `SELECT ?subject ?receiptIri ?manifest WHERE { GRAPH ${graph} { ?subject <${NS}request> <{requestId}> ; <${NS}revision> {revision} ; ${by} ?receiptIri . ?receiptIri <${q}attestProposal> ?manifest } } LIMIT 21`, {requestId: 'iri', revision: 'int'}, 21), + facts: define('facts', `SELECT ?s ?p ?kind ?value WHERE { GRAPH ${graph} { ?s ${by} <{receiptIri}> ; ?p ?o . FILTER(?p != ${by}) BIND(STR(?o) AS ?value) BIND(IF(isIRI(?o), "iri", IF(datatype(?o) = , "int", IF(datatype(?o) = , "str", "unsupported"))) AS ?kind) } } LIMIT 65`, {receiptIri: 'iri'}, 65), + links: define('links', `SELECT ?s WHERE { GRAPH ${graph} { ?s ${by} <{receiptIri}> } } LIMIT 65`, {receiptIri: 'iri'}, 65) + }; +} + +export function renderReaderQuery(def, params) { + exact(params, Object.keys(def.params)); + let query = def.template; + for (const [key, kind] of Object.entries(def.params)) { + const value = kind === 'iri' ? iri(params[key]) : integer(params[key]); + query = query.replaceAll(`{${key}}`, String(value)); + } + return query; +} + +export function readerRows(response, def, params) { + if (!response || response.query !== def.name || response.sparql !== renderReaderQuery(def, params) || response.truncated !== false || !Array.isArray(response.rows) || response.count !== response.rows.length || response.rows.length >= def.cap || response.error) throw new Unknown('Query identity or bounded population UNKNOWN'); + const seen = new Set(); + for (const row of response.rows) { + if (!row || typeof row !== 'object' || Array.isArray(row)) throw new Unknown('Malformed query row'); + const key = JSON.stringify(Object.keys(row).sort().map(k => [k, row[k]])); + if (seen.has(key)) throw new Unknown('Duplicate query row'); + seen.add(key); + } + return response.rows; +} + +function creationBinding(response, expected) { + if (response?.count !== 1 || response.graphs?.length !== 1) throw new Unknown('Exact registered graph required'); + const graph = response.graphs[0]; + if (graph.iri !== expected.iri || graph.class !== 'committed' || !Number.isSafeInteger(graph.g) || graph.g <= 0 || !expected.creation || JSON.stringify(graph.creation) !== JSON.stringify(expected.creation)) throw new Unknown('Graph creation identity changed'); +} + +export function readerTransport(endpoint, fetcher = globalThis.fetch) { + const origin = new URL(endpoint); + if (origin.protocol !== 'https:' || origin.origin + '/' !== endpoint || origin.username || origin.password || typeof fetcher !== 'function') throw new Unknown('Explicit HTTPS reader origin required'); + return async (address, {body, signal, cap}) => { + const url = new URL(address); + if (url.origin !== origin.origin || !['/graphs', '/ask', '/attest/receipt'].includes(url.pathname) || !Number.isSafeInteger(cap) || cap < 1 || cap > 262144) throw new Unknown('Reader route or byte bound refused'); + if (url.pathname === '/graphs' ? body !== undefined || [...url.searchParams.keys()].join(',') !== 'graph' : url.search !== '' || body === undefined) throw new Unknown('Reader route parameters refused'); + const response = await fetcher(url.href, {method: body === undefined ? 'GET' : 'POST', body: body === undefined ? undefined : JSON.stringify(body), signal, redirect: 'error', credentials: 'omit', cache: 'no-store', headers: {'Content-Type': 'application/json', 'X-Quipu-Client': 'daily-brief'}}); + if (!response.ok || response.redirected || response.url && new URL(response.url).origin !== origin.origin || !response.body || !/^application\/json(?:;|$)/i.test(response.headers.get('content-type') ?? '')) throw new Unknown('Reader HTTP response unavailable'); + const reader = response.body.getReader(), chunks = []; let size = 0; + try { + while (true) { + if (signal.aborted) throw new Unknown('Reader response aborted'); + const {done, value} = await reader.read(); if (done) break; + size += value.byteLength; + if (size > cap) throw new Unknown('Reader response exceeds byte bound'); + chunks.push(value); + } + const bytes = new Uint8Array(size); let offset = 0; + for (const chunk of chunks) {bytes.set(chunk, offset); offset += chunk.byteLength;} + return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes)); + } finally {await reader.cancel().catch(() => {}); reader.releaseLock();} + }; +} + +// The injected transport accepts (url, {body, signal, cap}), enforces the byte +// cap while streaming and propagates abort. This adapter also bounds decoded +// replies, and refuses overlapping cycles. It selects no default endpoint. +export class DeckReader { + constructor({endpoint, boundary, operator, provenanceNamespace, queryHashes, registrations, request, deadlineMs = 6000}) { + const url = new URL(endpoint); + if (url.protocol !== 'https:' || url.origin + '/' !== endpoint || url.username || url.password || typeof request !== 'function' || !Number.isInteger(deadlineMs) || deadlineMs < 1 || deadlineMs > 6000) throw new Unknown('Explicit bounded HTTPS reader required'); + this.endpoint = endpoint; this.boundary = scope(boundary); this.operator = identity(operator); + this.definitions = readerDefinitions(this.boundary, provenanceNamespace); + exact(queryHashes, Object.keys(this.definitions)); + this.queryHashes = structuredClone(queryHashes); this.registrations = structuredClone(registrations); + this.request = request; this.deadlineMs = deadlineMs; this.busy = false; + } + async read(requestId) { + iri(requestId); + if (this.busy) throw new Unknown('Reader cycle already active'); + this.busy = true; + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), this.deadlineMs); + let aborted; + const abort = new Promise((_, reject) => {aborted = () => reject(new Unknown('Reader cycle deadline exceeded')); ctl.signal.addEventListener('abort', aborted, {once: true});}); + const call = async (path, body, cap = 262144) => { + if (ctl.signal.aborted) throw new Unknown('Reader cycle deadline exceeded'); + const value = await Promise.race([this.request(new URL(path, this.endpoint).href, {body, signal: ctl.signal, cap}), abort]); + if (utf8(JSON.stringify(value)).length > cap || ctl.signal.aborted) throw new Unknown('Reader response exceeds bound'); + return value; + }; + const ask = async (role, params) => { + const def = this.definitions[role]; + return readerRows(await call('/ask', {name: def.name, params}), def, params); + }; + const requestRow = async () => { + const rows = await ask('request', {requestId}); + if (rows.length !== 1) throw new Unknown('Request revision missing or ambiguous'); + exact(rows[0], ['revision', 'revisionIri', 'manifest']); + const row = {graph: this.boundary.requestGraph, requestId, currentRevision: rows[0].revision, revisionIri: rows[0].revisionIri, manifest: rows[0].manifest, complete: true}; + readRequest(row, this.boundary); return row; + }; + const itemRow = async askValue => { + const items = await ask('item', {seed: askValue.seed}); + if (items.length !== 1) throw new Unknown('Item revision/status ambiguous'); + exact(items[0], ['revision', 'status']); integer(items[0].revision); iri(items[0].status); + if (items[0].revision !== askValue.itemRevision || items[0].status !== this.boundary.openStatus) throw new Unknown('Changed or closed item'); + return {graph: this.boundary.itemGraph, seed: askValue.seed, ...items[0]}; + }; + try { + for (const [role, def] of Object.entries(this.definitions)) { + if (this.queryHashes[role] !== await sha256(utf8(JSON.stringify(def)))) throw new Unknown('Unadmitted reader definition'); + } + const graphs = [this.boundary.requestGraph, this.boundary.answerGraph, this.boundary.itemGraph]; + if (!Array.isArray(this.registrations) || this.registrations.length !== 3 || new Set(this.registrations.map(r => r.iri)).size !== 3) throw new Unknown('Three graph creation receipts required'); + for (const graph of graphs) { + const expected = this.registrations.find(r => r.iri === graph); + if (!expected) throw new Unknown('Graph registration receipt missing'); + creationBinding(await call('/graphs?graph=' + encodeURIComponent(graph)), expected); + } + const first = await requestRow(), askValue = readRequest(first, this.boundary); + const freshItem = await itemRow(askValue); + const candidates = await ask('answers', {requestId, revision: askValue.revision}); + const rows = [], operations = new Set(); + // At most two requests in flight; no fanout of one request per answer. + for (const candidate of candidates) { + exact(candidate, ['subject', 'receiptIri', 'manifest']); + if (!/^urn:quipu:attest:[0-9a-f]{64}$/.test(candidate.receiptIri) || operations.has(candidate.subject)) throw new Unknown('Ambiguous answer provenance'); + operations.add(candidate.subject); + const row = {graph: this.boundary.answerGraph, ...candidate}; + const receipt = await call('/attest/receipt', {nonce: candidate.receiptIri.slice('urn:quipu:attest:'.length)}, 131072); + await readAnswer(row, receipt, askValue, this.boundary, this.operator); + const proof = await receiptProof(receipt, decodeProposal(candidate.manifest), this.operator, candidate.receiptIri.slice('urn:quipu:attest:'.length)); + const [facts, links] = await Promise.all([ask('facts', {receiptIri: candidate.receiptIri}), ask('links', {receiptIri: candidate.receiptIri})]); + const datums = facts.map(f => { + exact(f, ['s', 'p', 'kind', 'value']); + text(f.value, 32768, true); + if (!['iri', 'str', 'int'].includes(f.kind) || f.kind === 'int' && !/^-?(0|[1-9][0-9]*)$/.test(f.value)) throw new Unknown('Unsupported read-back term'); + return {s: f.s, p: f.p, o: {kind: f.kind, value: f.kind === 'int' ? Number(f.value) : f.value}}; + }); + const attestedBy = links.map(link => {exact(link, ['s']); return {s: link.s, receiptIri: candidate.receiptIri};}); + readbackProof({graph: this.boundary.answerGraph, datums, attestedBy, complete: true}, proof); + rows.push({row, receipt}); + } + const last = await requestRow(); + if (JSON.stringify(first) !== JSON.stringify(last)) throw new Unknown('Request changed during read'); + if (JSON.stringify(freshItem) !== JSON.stringify(await itemRow(askValue))) throw new Unknown('Item changed during read'); + return {complete: true, requestRow: last, freshItem, rows}; + } finally { + clearTimeout(timer); ctl.signal.removeEventListener('abort', aborted); ctl.abort(); this.busy = false; + } + } +}