diff --git a/buyer/Dockerfile b/buyer/Dockerfile index ec79ca3..eb81db6 100644 --- a/buyer/Dockerfile +++ b/buyer/Dockerfile @@ -27,6 +27,11 @@ ARG NODE_VERSION=20.19.0 FROM node:${NODE_VERSION}-alpine AS builder WORKDIR /repo +# better-sqlite3 (used by the response archive) ships native bindings — +# python3 + make + g++ are needed during pnpm install for the prebuild +# install / fallback compile. Toolchain stays in the BUILDER stage only. +RUN apk add --no-cache python3 make g++ + RUN corepack enable && corepack prepare pnpm@10.33.2 --activate COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ @@ -39,6 +44,15 @@ COPY indexer/package.json ./indexer/package.json # must install with dev deps included (default). RUN pnpm install --frozen-lockfile --filter '@marketplace/buyer...' +# better-sqlite3 native binding fetch (mirrors indexer/Dockerfile). pnpm 10's +# approve-builds gate skips the postinstall on native modules, so we manually +# resolve the binary into build/Release/. +RUN BSQ_DIR=$(ls -d node_modules/.pnpm/better-sqlite3@*/node_modules/better-sqlite3 2>/dev/null | head -1) \ + && [ -n "$BSQ_DIR" ] || (echo "FATAL: better-sqlite3 not in pnpm store" && exit 1) \ + && echo "rebuilding native binding at: $BSQ_DIR" \ + && (cd "$BSQ_DIR" && (npx --yes prebuild-install --runtime=node || npm install --build-from-source --no-save)) \ + && ls -la "$BSQ_DIR/build/Release/better_sqlite3.node" + # libsodium-wrappers-sumo (transitive via @lucid-evolution/lucid → CML) does # `import "./libsodium-sumo.mjs"` from its wrapper, but pnpm's strict store # layout puts the .mjs in a sibling pkg, breaking the relative import. Patch diff --git a/buyer/package.json b/buyer/package.json index effa4ff..62cce3f 100644 --- a/buyer/package.json +++ b/buyer/package.json @@ -15,6 +15,7 @@ "@noble/ed25519": "^2.3.0", "@noble/hashes": "^1.8.0", "bech32": "^2.0.0", + "better-sqlite3": "^11.9.1", "express": "^4.21.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -24,6 +25,7 @@ "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", + "@types/better-sqlite3": "^7.6.12", "@types/express": "^5.0.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", diff --git a/buyer/src/config.ts b/buyer/src/config.ts index 8bd4eb0..db94ca2 100644 --- a/buyer/src/config.ts +++ b/buyer/src/config.ts @@ -15,6 +15,11 @@ * When unset, /v1/synth-speech responds 503 (the * capability stays disabled — the rest of the * buyer-app boots normally). + * ARCHIVE_DIR — Directory where the response archive (SQLite + * metadata + per-escrow artefact files) lives. + * Defaults to "./data/archive" relative to cwd. + * When unset, archive is disabled (responses are + * not persisted; /v1/responses* return 503). */ const HEX64_RE = /^[0-9a-fA-F]{64}$/; @@ -28,6 +33,7 @@ export interface BuyerConfig { ogmiosUrl: string; liveChain: boolean; ttsPiperBaseUrl: string; + archiveDir: string; } function requireField(env: Record, name: string): string { @@ -80,5 +86,11 @@ export function loadConfig(env: Record): BuyerConfig // shape here; an unreachable URL surfaces as a clear 502 from the proxy. const ttsPiperBaseUrl = env.TTS_PIPER_BASE_URL ?? ""; - return { privKeyHex, indexerUrl, port, networkId, ogmiosUrl, liveChain, ttsPiperBaseUrl }; + // ARCHIVE_DIR — host path (typically a docker bind-mount) where the + // response archive lives. Default is a per-process relative dir to keep + // dev ergonomics; in production the compose file overrides to a + // bind-mounted /repo/data/archive. + const archiveDir = env.ARCHIVE_DIR ?? "./data/archive"; + + return { privKeyHex, indexerUrl, port, networkId, ogmiosUrl, liveChain, ttsPiperBaseUrl, archiveDir }; } diff --git a/buyer/src/db/archive.ts b/buyer/src/db/archive.ts new file mode 100644 index 0000000..c9c0181 --- /dev/null +++ b/buyer/src/db/archive.ts @@ -0,0 +1,241 @@ +/** + * buyer/src/db/archive.ts — response archive (SQLite metadata + filesystem + * artefacts). + * + * Open one Archive instance at boot, persist every completed lifecycle, and + * expose read APIs for the SPA's history page + dispute-verification flows. + * + * Storage layout under ARCHIVE_DIR (typically a host-bind-mounted dir): + * + * archive.db — SQLite metadata index + * _/request.json — what the buyer sent + * _/response.{txt,mp3,wav,...} — what the supplier returned + * + * The "#" in the escrow ref is replaced with "_" so the directory name is a + * single valid path segment on every filesystem. + * + * This module is intentionally synchronous (better-sqlite3 + fs.writeFileSync) + * because the volume is low (≤ 1 record per chat/TTS request, ~100KB worst + * case for audio) and the simplicity is worth more than async overhead. + */ + +import { mkdirSync, writeFileSync, readFileSync, existsSync } from "fs"; +import { join, resolve } from "path"; +import Database from "better-sqlite3"; +import type { Database as BetterDatabase } from "better-sqlite3"; +import { CREATE_TABLES_SQL } from "./schema.js"; + +/** Convert "#" to a safe directory name. */ +function escrowRefToDir(escrow_ref: string): string { + return escrow_ref.replace("#", "_"); +} + +/** Pick a sensible file extension for a response based on content type. */ +function extFromContentType(ct: string): string { + if (ct.startsWith("audio/mpeg")) return "mp3"; + if (ct.startsWith("audio/wav") || ct.startsWith("audio/wave") || ct.startsWith("audio/x-wav")) return "wav"; + if (ct.startsWith("audio/opus") || ct.startsWith("audio/ogg")) return "opus"; + if (ct.startsWith("audio/aac")) return "aac"; + if (ct.startsWith("audio/flac")) return "flac"; + if (ct.startsWith("audio/")) return ct.slice("audio/".length).split(";")[0] || "bin"; + if (ct.startsWith("application/json")) return "json"; + if (ct.startsWith("text/plain")) return "txt"; + return "bin"; +} + +export interface ArchiveRow { + escrow_ref: string; + posted_at: number; + completed_at: number; + capability_id: string; + supplier_pkh: string; + model: string; + payment_lovelace: string; + request_filename: string; + response_filename: string; + response_content_type: string; + response_byte_length: number; + receipt_json: string; + receipt_signature: string; +} + +export interface PersistChatParams { + escrow_ref: string; + posted_at: number; + capability_id: string; + supplier_pkh: string; + model: string; + payment_lovelace: string; + /** The full messages[] array, pre-canonicalisation. */ + request_messages: unknown; + /** The assistant message object — `{role:"assistant", content:"..."}` — + * canonicalised by canonical(...) on the supplier side, so the bytes + * stored here MUST match exactly what was hashed for the receipt. */ + response_canonical: string; + receipt: Record; + receipt_signature: string; +} + +export interface PersistTtsParams { + escrow_ref: string; + posted_at: number; + capability_id: string; + supplier_pkh: string; + model: string; + payment_lovelace: string; + /** The TTS request envelope `{text, voice, format, speed}`. */ + request_envelope: unknown; + /** Raw audio bytes — exactly the same buffer the supplier hashed. */ + response_audio: Uint8Array; + /** From the upstream Piper response, e.g. "audio/mpeg". */ + response_content_type: string; + receipt: Record; + receipt_signature: string; +} + +export class ResponseArchive { + private readonly db: BetterDatabase; + private readonly archiveDir: string; + private readonly insertStmt; + private readonly listStmt; + private readonly getStmt; + + constructor(archiveDir: string) { + this.archiveDir = resolve(archiveDir); + mkdirSync(this.archiveDir, { recursive: true }); + const dbPath = join(this.archiveDir, "archive.db"); + this.db = new Database(dbPath); + this.db.pragma("journal_mode = WAL"); + this.db.exec(CREATE_TABLES_SQL); + + this.insertStmt = this.db.prepare(` + INSERT OR REPLACE INTO responses ( + escrow_ref, posted_at, completed_at, capability_id, supplier_pkh, + model, payment_lovelace, request_filename, response_filename, + response_content_type, response_byte_length, receipt_json, receipt_signature + ) VALUES ( + @escrow_ref, @posted_at, @completed_at, @capability_id, @supplier_pkh, + @model, @payment_lovelace, @request_filename, @response_filename, + @response_content_type, @response_byte_length, @receipt_json, @receipt_signature + ) + `); + + this.listStmt = this.db.prepare(` + SELECT * FROM responses + ORDER BY completed_at DESC + LIMIT @limit + `); + + this.getStmt = this.db.prepare(`SELECT * FROM responses WHERE escrow_ref = @escrow_ref`); + } + + /** Resolve the per-escrow artefact directory; create it if missing. */ + private artefactDir(escrow_ref: string): string { + const dir = join(this.archiveDir, escrowRefToDir(escrow_ref)); + mkdirSync(dir, { recursive: true }); + return dir; + } + + /** Persist a completed chat lifecycle. Idempotent on escrow_ref. */ + persistChat(p: PersistChatParams): ArchiveRow { + const dir = this.artefactDir(p.escrow_ref); + const requestFilename = "request.json"; + const responseFilename = "response.json"; + + writeFileSync( + join(dir, requestFilename), + JSON.stringify({ messages: p.request_messages }, null, 2), + "utf8", + ); + // Store the EXACT canonical bytes the supplier hashed for response_hash — + // not pretty-printed JSON. This is what verification reads back. + writeFileSync(join(dir, responseFilename), p.response_canonical, "utf8"); + + const responseBytes = Buffer.byteLength(p.response_canonical, "utf8"); + const row: ArchiveRow = { + escrow_ref: p.escrow_ref, + posted_at: p.posted_at, + completed_at: Date.now(), + capability_id: p.capability_id, + supplier_pkh: p.supplier_pkh, + model: p.model, + payment_lovelace: p.payment_lovelace, + request_filename: requestFilename, + response_filename: responseFilename, + response_content_type: "application/json", + response_byte_length: responseBytes, + receipt_json: JSON.stringify(p.receipt), + receipt_signature: p.receipt_signature, + }; + this.insertStmt.run(row as unknown as Record); + return row; + } + + /** Persist a completed TTS lifecycle. Idempotent on escrow_ref. */ + persistTts(p: PersistTtsParams): ArchiveRow { + const dir = this.artefactDir(p.escrow_ref); + const ext = extFromContentType(p.response_content_type); + const requestFilename = "request.json"; + const responseFilename = `response.${ext}`; + + writeFileSync( + join(dir, requestFilename), + JSON.stringify(p.request_envelope, null, 2), + "utf8", + ); + writeFileSync(join(dir, responseFilename), p.response_audio); + + const row: ArchiveRow = { + escrow_ref: p.escrow_ref, + posted_at: p.posted_at, + completed_at: Date.now(), + capability_id: p.capability_id, + supplier_pkh: p.supplier_pkh, + model: p.model, + payment_lovelace: p.payment_lovelace, + request_filename: requestFilename, + response_filename: responseFilename, + response_content_type: p.response_content_type, + response_byte_length: p.response_audio.byteLength, + receipt_json: JSON.stringify(p.receipt), + receipt_signature: p.receipt_signature, + }; + this.insertStmt.run(row as unknown as Record); + return row; + } + + /** Most recent first; bounded so the SPA's history page stays light. */ + list(limit = 100): ArchiveRow[] { + return this.listStmt.all({ limit }) as ArchiveRow[]; + } + + /** Single-row lookup by canonical "#" key. */ + get(escrow_ref: string): ArchiveRow | null { + return (this.getStmt.get({ escrow_ref }) as ArchiveRow | undefined) ?? null; + } + + /** Read the request artefact bytes (always JSON, always small). */ + readRequest(escrow_ref: string): Buffer | null { + const row = this.get(escrow_ref); + if (!row) return null; + const path = join(this.archiveDir, escrowRefToDir(escrow_ref), row.request_filename); + return existsSync(path) ? readFileSync(path) : null; + } + + /** Read the response artefact bytes (text for chat, audio for TTS). */ + readResponse(escrow_ref: string): { bytes: Buffer; contentType: string; filename: string } | null { + const row = this.get(escrow_ref); + if (!row) return null; + const path = join(this.archiveDir, escrowRefToDir(escrow_ref), row.response_filename); + if (!existsSync(path)) return null; + return { + bytes: readFileSync(path), + contentType: row.response_content_type, + filename: row.response_filename, + }; + } + + close(): void { + this.db.close(); + } +} diff --git a/buyer/src/db/schema.ts b/buyer/src/db/schema.ts new file mode 100644 index 0000000..f1894a0 --- /dev/null +++ b/buyer/src/db/schema.ts @@ -0,0 +1,53 @@ +/** + * buyer/src/db/schema.ts — schema for the buyer-app's response archive. + * + * Persists every completed lifecycle (chat or TTS) so the buyer has an + * off-chain audit trail to pair with the on-chain receipt commitment. + * + * The actual artefacts (the assistant message JSON for chat, the audio + * bytes for TTS, the request envelope) live as files under ARCHIVE_DIR; + * this DB only stores metadata + the receipt. + * + * Verification flow per record (for disputes): + * 1. Read response file → bytes B + * 2. Confirm sha256(B-or-canonical(B)) === receipt.response_hash + * 3. Verify Ed25519(receipt_json, supplier_pkh.pubkey) === receipt_signature + * 4. Confirm receipt's escrow_ref's Submit tx on chain commits to + * sha256(canonical(receipt_json)) + * + * If all four match, the record is genuine and immutable. If any disagrees, + * either the buyer or supplier is lying about what was delivered. + */ + +export const CREATE_TABLES_SQL = ` + CREATE TABLE IF NOT EXISTS responses ( + -- "#" of the original PostEscrow output. Same key the + -- on-chain lifecycle and the indexer use, so cross-referencing is trivial. + escrow_ref TEXT PRIMARY KEY, + + posted_at INTEGER NOT NULL, -- POSIX ms (escrow datum) + completed_at INTEGER NOT NULL, -- POSIX ms (server.ts persist time) + capability_id TEXT NOT NULL, -- "llm.text.generate.v1" | "audio.synthesize.piper.v1" + supplier_pkh TEXT NOT NULL, -- 28-byte hex + model TEXT NOT NULL, -- as advertised on chain + payment_lovelace TEXT NOT NULL, -- string-encoded bigint + + -- Filesystem references — relative to ARCHIVE_DIR//. + -- escrow_ref_safe replaces "#" with "_" so the dir name is a single + -- valid path segment. + request_filename TEXT NOT NULL, -- e.g. "request.json" + response_filename TEXT NOT NULL, -- e.g. "response.txt" or "response.mp3" + response_content_type TEXT NOT NULL, -- "text/plain" | "audio/mpeg" | … + response_byte_length INTEGER NOT NULL, + + -- The canonical receipt + signature, exactly as delivered by the supplier. + -- receipt_json is the JSON-stringified canonical form (same bytes the + -- supplier hashed to produce the result_receipt_hash committed on chain). + receipt_json TEXT NOT NULL, + receipt_signature TEXT NOT NULL -- 128-char Ed25519 hex + ); + + CREATE INDEX IF NOT EXISTS idx_responses_completed ON responses(completed_at DESC); + CREATE INDEX IF NOT EXISTS idx_responses_capability ON responses(capability_id); + CREATE INDEX IF NOT EXISTS idx_responses_supplier ON responses(supplier_pkh); +`; diff --git a/buyer/src/index.ts b/buyer/src/index.ts index 9eee04b..1c60890 100644 --- a/buyer/src/index.ts +++ b/buyer/src/index.ts @@ -15,6 +15,7 @@ import { loadConfig } from "./config.js"; import { createApp } from "./server.js"; import { buildChainProvider } from "./chain.js"; import { Marketplace, MemoryTaskHistoryStore } from "./sdk/index.js"; +import { ResponseArchive } from "./db/archive.js"; import type { WalletKey } from "@marketplace/shared/tx"; // Wire ed25519 sha512 hook (idempotent — same as receipt/sign.ts). @@ -74,6 +75,22 @@ export async function runMain(env: Record): Promise< }); const distPath = resolve(process.cwd(), "buyer/dist"); + + // Open the response archive (SQLite + filesystem under ARCHIVE_DIR). + // Boot is non-fatal: if the archive can't be opened (e.g. permissions), + // log + continue without it — /v1/responses* will respond 503 but the + // lifecycle endpoints still work. + let archive: ResponseArchive | undefined; + try { + archive = new ResponseArchive(config.archiveDir); + console.log(`[buyer] response archive at ${config.archiveDir}`); + } catch (err) { + console.error( + `[buyer] failed to open response archive at ${config.archiveDir}:`, + err instanceof Error ? err.message : String(err), + ); + } + const app = createApp({ distPath, chain, @@ -81,6 +98,7 @@ export async function runMain(env: Record): Promise< indexerUrl: config.indexerUrl, marketplace, ttsPiperBaseUrl: config.ttsPiperBaseUrl, + archive, }); const server = app.listen(config.port, () => { diff --git a/buyer/src/server.ts b/buyer/src/server.ts index 5dacf9e..2b20402 100644 --- a/buyer/src/server.ts +++ b/buyer/src/server.ts @@ -25,7 +25,9 @@ import type { ChainProvider } from "@marketplace/shared/chain"; import type { WalletKey } from "@marketplace/shared/tx"; import type { Marketplace } from "./sdk/Marketplace.js"; import type { ChatMessage } from "@marketplace/shared/tx"; +import { canonicalize } from "@marketplace/shared/cbor"; import { runAccept } from "./cli/acceptFlow.js"; +import type { ResponseArchive } from "./db/archive.js"; const ESCROW_REF_RE = /^([0-9a-f]{64})#(\d+)$/; @@ -64,6 +66,12 @@ export interface AppDeps { * `/v1/synth-speech` proxies to. Falls back to the public Vector-testnet * host when not set. Endpoint stays disabled (503) when this is empty. */ ttsPiperBaseUrl?: string; + /** Persistent off-chain audit trail. When provided, every successful + * submitPrompt / submitTts call writes a row + on-disk artefacts and + * /v1/responses* exposes the archive read-only. When undefined the + * archive endpoints respond 503 and the lifecycle still works (just + * without history beyond the indexer). */ + archive?: ResponseArchive; /** Injectable fetch for tests. Defaults to globalThis.fetch. */ fetchImpl?: typeof globalThis.fetch; } @@ -297,13 +305,49 @@ export function createApp(deps: AppDeps): Express { payment_lovelace, max_output_tokens, }); + const escrowRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`; + + // Persist BEFORE returning so navigation away never loses the audit + // trail. Best-effort — a write failure is logged but doesn't fail + // the response (the on-chain receipt is still authoritative). + if (deps.archive) { + try { + // Reproduce the EXACT bytes the supplier hashed for response_hash: + // sha256(canonicalize({role:"assistant", content: ...})). canonicalize + // sorts keys alphabetically (content < role) per JCS — JSON.stringify + // would preserve insertion order and yield different bytes → wrong + // sha256 → "Verify hash" mismatch on the SPA. + const canonicalAssistant = canonicalize({ + role: "assistant", + content: result.response, + }); + deps.archive.persistChat({ + escrow_ref: escrowRefStr, + posted_at: Date.now(), + capability_id: "llm.text.generate.v1", + supplier_pkh: result.receipt.supplier_pkh, + model: result.receipt.model, + payment_lovelace: payment_lovelace.toString(), + request_messages: messages, + response_canonical: canonicalAssistant, + receipt: result.receipt as unknown as Record, + receipt_signature: result.receiptSignature, + }); + } catch (err) { + console.error( + `[buyer] archive.persistChat failed for ${escrowRefStr}:`, + err instanceof Error ? err.message : String(err), + ); + } + } + // Wrap the assistant text in OpenAI-shape `choices[0].message` so // downstream clients (PromptForm) can unpack uniformly. return res.status(200).json({ choices: [{ index: 0, message: { role: "assistant", content: result.response }, finish_reason: "stop" }], receipt: result.receipt, receipt_signature: result.receiptSignature, - escrow_ref: `${result.escrowRef.txHash}#${result.escrowRef.index}`, + escrow_ref: escrowRefStr, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -468,6 +512,35 @@ export function createApp(deps: AppDeps): Express { speed, payment_lovelace, }); + const escrowRefStr = `${result.escrowRef.txHash}#${result.escrowRef.index}`; + + if (deps.archive) { + try { + // Decode base64 audio back to raw bytes — storing them on disk in + // their original form keeps verification simple (sha256 over the + // same bytes the supplier hashed for response_hash). + const audio = Buffer.from(result.audio_b64, "base64"); + deps.archive.persistTts({ + escrow_ref: escrowRefStr, + posted_at: Date.now(), + capability_id: "audio.synthesize.piper.v1", + supplier_pkh: result.receipt.supplier_pkh, + model: result.receipt.model, + payment_lovelace: payment_lovelace.toString(), + request_envelope: { text, voice, format, speed }, + response_audio: audio, + response_content_type: result.content_type, + receipt: result.receipt as unknown as Record, + receipt_signature: result.receiptSignature, + }); + } catch (err) { + console.error( + `[buyer] archive.persistTts failed for ${escrowRefStr}:`, + err instanceof Error ? err.message : String(err), + ); + } + } + return res.status(200).json({ audio_b64: result.audio_b64, format: result.format, @@ -475,7 +548,7 @@ export function createApp(deps: AppDeps): Express { byte_length: result.byte_length, receipt: result.receipt, receipt_signature: result.receiptSignature, - escrow_ref: `${result.escrowRef.txHash}#${result.escrowRef.index}`, + escrow_ref: escrowRefStr, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -616,6 +689,107 @@ export function createApp(deps: AppDeps): Express { } }); + // ── Response archive (off-chain audit trail) ──────────────────────── + // Read-only views over the buyer-app's persistent record of every + // completed lifecycle. The archive persists at the END of submitPrompt + // / submitTts, BEFORE returning to the SPA, so navigation away never + // loses the artefact. Each row points at on-disk files in ARCHIVE_DIR + // that contain the exact bytes the supplier hashed for response_hash — + // verifiable by computing sha256 over the file and comparing to + // receipt.response_hash, then verifying the receipt's Ed25519 signature + // against the supplier's published pub_key_hex. Combined with the + // on-chain Submit tx's result_receipt_hash commitment, this is a full + // audit trail for dispute resolution. + if (!deps.archive) { + const disabledMsg = "buyer-app booted without ARCHIVE_DIR; /v1/responses* disabled"; + app.get(/^\/v1\/responses(\/.*)?$/, (_req: Request, res: Response) => + jsonError(res, 503, "service_unavailable", disabledMsg), + ); + } else { + const archive = deps.archive; + + // GET /v1/responses?limit=100 — list metadata, newest first. + app.get("/v1/responses", (req: Request, res: Response) => { + const limitRaw = (req.query.limit as string | undefined) ?? ""; + const limit = /^[1-9]\d*$/.test(limitRaw) ? Math.min(Number(limitRaw), 500) : 100; + const rows = archive.list(limit); + // Strip the receipt_json string into a parsed object for nicer SPA + // rendering, but keep receipt_signature alongside for verification. + const view = rows.map((r) => ({ + escrow_ref: r.escrow_ref, + posted_at: r.posted_at, + completed_at: r.completed_at, + capability_id: r.capability_id, + supplier_pkh: r.supplier_pkh, + model: r.model, + payment_lovelace: r.payment_lovelace, + response_content_type: r.response_content_type, + response_byte_length: r.response_byte_length, + receipt: JSON.parse(r.receipt_json), + receipt_signature: r.receipt_signature, + })); + return res.status(200).json({ responses: view }); + }); + + // The archive stores escrow refs in canonical "#" form. + // URL paths can't carry "#" (fragment delimiter) so the API uses the + // dir-safe "_" form in path segments — matches the + // filesystem layout on disk for trivial debugging. Server normalises + // back to "#" before SQLite lookup. + const URL_REF = /^([0-9a-f]{64})_(\d+)$/; + function normaliseUrlRef(urlRef: string): string | null { + const m = URL_REF.exec(urlRef); + return m ? `${m[1]}#${m[2]}` : null; + } + + // GET /v1/responses/:escrow_ref — single-row metadata + receipt. + app.get("/v1/responses/:ref", (req: Request, res: Response) => { + const ref = normaliseUrlRef(typeof req.params.ref === "string" ? req.params.ref : ""); + if (!ref) return jsonError(res, 400, "ref_invalid", + 'path segment must be "<64hex>_" (URL-safe form of escrow_ref)'); + const row = archive.get(ref); + if (!row) { + return jsonError(res, 404, "not_found", `no archive entry for ${ref}`); + } + return res.status(200).json({ + escrow_ref: row.escrow_ref, + posted_at: row.posted_at, + completed_at: row.completed_at, + capability_id: row.capability_id, + supplier_pkh: row.supplier_pkh, + model: row.model, + payment_lovelace: row.payment_lovelace, + response_content_type: row.response_content_type, + response_byte_length: row.response_byte_length, + request_filename: row.request_filename, + response_filename: row.response_filename, + receipt: JSON.parse(row.receipt_json), + receipt_signature: row.receipt_signature, + }); + }); + + // GET /v1/responses/:escrow_ref/request — raw request artefact bytes. + app.get("/v1/responses/:ref/request", (req: Request, res: Response) => { + const ref = normaliseUrlRef(typeof req.params.ref === "string" ? req.params.ref : ""); + if (!ref) return jsonError(res, 400, "ref_invalid", "bad ref"); + const bytes = archive.readRequest(ref); + if (!bytes) return jsonError(res, 404, "not_found", `no request artefact for ${ref}`); + res.setHeader("Content-Type", "application/json"); + return res.send(bytes); + }); + + // GET /v1/responses/:escrow_ref/response — raw response artefact bytes. + app.get("/v1/responses/:ref/response", (req: Request, res: Response) => { + const ref = normaliseUrlRef(typeof req.params.ref === "string" ? req.params.ref : ""); + if (!ref) return jsonError(res, 400, "ref_invalid", "bad ref"); + const r = archive.readResponse(ref); + if (!r) return jsonError(res, 404, "not_found", `no response artefact for ${ref}`); + res.setHeader("Content-Type", r.contentType); + res.setHeader("Content-Disposition", `inline; filename="${r.filename}"`); + return res.send(r.bytes); + }); + } + if (deps.distPath) { let isDir = false; try { diff --git a/buyer/src/ui/pages/TaskHistory.tsx b/buyer/src/ui/pages/TaskHistory.tsx index b693b7c..0ee6599 100644 --- a/buyer/src/ui/pages/TaskHistory.tsx +++ b/buyer/src/ui/pages/TaskHistory.tsx @@ -1,19 +1,23 @@ /** * buyer/src/ui/pages/TaskHistory.tsx — full lifecycle history for this buyer. * - * Source of truth: the indexer. We fetch every escrow row for the buyer's - * pkh (proxied through the buyer-app's /v1/indexer/* passthrough), group by - * `posted_at` (which is preserved across state transitions by the - * `expect_unchanged_modulo_state` validator invariant), and render one card - * per lifecycle. + * Two data sources joined client-side: + * 1. /v1/indexer/escrows — chain state of every lifecycle this buyer + * participated in (Open → Claimed → Submitted → Accepted/etc). + * 2. /v1/responses — buyer-app's persistent archive (request + + * response artefacts on disk + signed receipt + sha256 of response + * bytes pre-committed via the on-chain Submit tx). * - * Why server-side / indexer-driven: the SPA previously read from a - * LocalStorageTaskHistoryStore that was written by `marketplace.submitPrompt`. - * After UX-2b moved submitPrompt server-side, the browser SDK no longer - * writes anything, so localStorage was always empty. The chain itself is the - * canonical history; rendering directly from indexer rows means a fresh - * browser session immediately sees every prior lifecycle this buyer has - * participated in, on any device. + * Match key: lifecycle's first row (the original PostEscrow output ref) == + * archive row's `escrow_ref`. Lifecycles that completed get inline response + * rendering (audio player for TTS, JSON pretty-print for chat) plus a + * Verify-hash button that computes sha256 over the response bytes and + * compares to `receipt.response_hash`. + * + * The verify button only checks bytes-match-hash; it does NOT verify the + * supplier's Ed25519 signature on the receipt. That requires fetching the + * supplier's pub_key_hex from /capability and is deferred to a later + * iteration of this page. */ import { useEffect, useState, useMemo } from "react"; @@ -36,7 +40,31 @@ interface IndexerEscrowRow { created_slot: number; } -/** A lifecycle = all escrow rows that share posted_at (one buyer/supplier pair). */ +interface ArchiveReceipt { + prompt_hash: string; + response_hash: string; + model: string; + prompt_tokens: number; + completion_tokens: number; + wallclock_ms: number; + supplier_pkh: string; + escrow_ref: string; +} + +interface ArchiveRow { + escrow_ref: string; + posted_at: number; + completed_at: number; + capability_id: string; + supplier_pkh: string; + model: string; + payment_lovelace: string; + response_content_type: string; + response_byte_length: number; + receipt: ArchiveReceipt; + receipt_signature: string; +} + interface Lifecycle { posted_at: number; supplier_pkh: string; @@ -46,8 +74,11 @@ interface Lifecycle { supplier_bond_lovelace: string; advert_ref: string; prompt_hash: string; - rows: IndexerEscrowRow[]; // sorted ascending by created_slot - currentState: string; // = rows[rows.length-1].state + rows: IndexerEscrowRow[]; + currentState: string; + /** archive entry keyed by the original PostEscrow utxo_ref. May be null + * for in-flight or pre-archive lifecycles. */ + archive: ArchiveRow | null; } const STATE_PRIORITY: Record = { @@ -95,12 +126,16 @@ function statePill(state: string): string { } } -function groupByLifecycle(rows: IndexerEscrowRow[]): Lifecycle[] { +function refToUrlSafe(escrowRef: string): string { + return escrowRef.replace("#", "_"); +} + +function groupByLifecycle( + rows: IndexerEscrowRow[], + archive: Map, +): Lifecycle[] { const buckets = new Map(); for (const r of rows) { - // posted_at + supplier_pkh disambiguates if a buyer happens to post two - // escrows in the exact same ms (extremely unlikely on testnet but - // cheap defensive grouping). const key = `${r.posted_at}:${r.supplier_pkh}`; const arr = buckets.get(key); if (arr) arr.push(r); @@ -109,10 +144,8 @@ function groupByLifecycle(rows: IndexerEscrowRow[]): Lifecycle[] { const out: Lifecycle[] = []; for (const [, lcRows] of buckets) { lcRows.sort((a, b) => a.created_slot - b.created_slot); + const first = lcRows[0]; // original PostEscrow output ref — archive key const last = lcRows[lcRows.length - 1]; - // currentState is whichever row has the highest STATE_PRIORITY (handles - // out-of-order arrivals from the indexer; we don't rely on slot order - // alone). const top = lcRows.reduce((acc, r) => (STATE_PRIORITY[r.state] ?? 0) > (STATE_PRIORITY[acc.state] ?? 0) ? r : acc, last); @@ -127,17 +160,48 @@ function groupByLifecycle(rows: IndexerEscrowRow[]): Lifecycle[] { prompt_hash: last.prompt_hash, rows: lcRows, currentState: top.state, + archive: archive.get(first.utxo_ref) ?? null, }); } - // Newest first. out.sort((a, b) => b.posted_at - a.posted_at); return out; } +// SubtleCrypto sha256 → 32-byte hex string. +async function sha256Hex(bytes: ArrayBuffer): Promise { + const buf = await crypto.subtle.digest("SHA-256", bytes); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * For chat: receipt.response_hash = sha256_utf8(canonicalize({role:"assistant", + * content: ...})). The archive's response.json contains EXACTLY those bytes + * (the canonical form, not pretty-printed) so a sha256 over the file bytes + * yields the same hex as the receipt. + * + * For TTS: receipt.response_hash = sha256(audio_bytes). The archive's + * response.{mp3,wav,…} is the raw bytes. + * + * Either way, this function is a single sha256-over-bytes comparison. + */ +async function verifyResponseHash( + escrowRef: string, + expectedHash: string, +): Promise<{ ok: boolean; computed: string }> { + const resp = await fetch(`/v1/responses/${refToUrlSafe(escrowRef)}/response`); + if (!resp.ok) throw new Error(`fetch /response failed: ${resp.status}`); + const buf = await resp.arrayBuffer(); + const computed = await sha256Hex(buf); + return { ok: computed === expectedHash, computed }; +} + export default function TaskHistory() { const marketplace = useMarketplace(); const buyerPkh = marketplace.getWalletKey().pubKeyHash; const [rows, setRows] = useState(null); + const [archive, setArchive] = useState>(new Map()); const [error, setError] = useState(null); const load = async (): Promise => { @@ -148,12 +212,27 @@ export default function TaskHistory() { } setError(null); try { - const resp = await fetch(`/v1/indexer/escrows?buyer=${buyerPkh}`); - if (!resp.ok) { - throw new Error(`indexer responded ${resp.status} ${resp.statusText}`); + const [escrowsResp, archResp] = await Promise.all([ + fetch(`/v1/indexer/escrows?buyer=${buyerPkh}`), + fetch(`/v1/responses?limit=200`), + ]); + if (!escrowsResp.ok) { + throw new Error(`indexer responded ${escrowsResp.status} ${escrowsResp.statusText}`); } - const body = (await resp.json()) as IndexerEscrowRow[]; + const body = (await escrowsResp.json()) as IndexerEscrowRow[]; setRows(Array.isArray(body) ? body : []); + + // Archive endpoint may be 503 if the buyer-app booted without + // ARCHIVE_DIR — that's not fatal; we just render lifecycles + // without inline response bodies. + if (archResp.ok) { + const archBody = (await archResp.json()) as { responses: ArchiveRow[] }; + const m = new Map(); + for (const r of archBody.responses ?? []) m.set(r.escrow_ref, r); + setArchive(m); + } else { + setArchive(new Map()); + } } catch (err) { setError(err instanceof Error ? err.message : String(err)); setRows([]); @@ -163,8 +242,8 @@ export default function TaskHistory() { useEffect(() => { void load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [buyerPkh]); const lifecycles = useMemo( - () => (rows === null ? [] : groupByLifecycle(rows)), - [rows], + () => (rows === null ? [] : groupByLifecycle(rows, archive)), + [rows, archive], ); return ( @@ -197,52 +276,233 @@ export default function TaskHistory() { ) : (
    {lifecycles.map((lc) => ( -
  • -
    -
    - {fmtTime(lc.posted_at)} -
    - - {lc.currentState} - -
    -
    - {lc.capability_id} - · - {fmtAda(lc.payment_lovelace)} payment - · - {fmtAda(lc.buyer_bond_lovelace)} bond - · - - supplier {lc.supplier_pkh.slice(0, 12)}… - -
    -
    - {lc.rows.map((r) => ( -
    - - {r.state} - - {txHashFromRef(r.utxo_ref)} - slot {r.created_slot} -
    - ))} -
    -
  • + ))}
)} ); } + +function LifecycleCard({ lc }: { lc: Lifecycle }): JSX.Element { + return ( +
  • +
    +
    + {fmtTime(lc.posted_at)} +
    + + {lc.currentState} + +
    +
    + {lc.capability_id} + {lc.archive && ( + <> + · + model: {lc.archive.model} + + )} + · + {fmtAda(lc.payment_lovelace)} payment + · + {fmtAda(lc.buyer_bond_lovelace)} bond + · + + supplier {lc.supplier_pkh.slice(0, 12)}… + +
    + + {lc.archive && } + +
    + {lc.rows.map((r) => ( +
    + {r.state} + {txHashFromRef(r.utxo_ref)} + slot {r.created_slot} +
    + ))} +
    +
  • + ); +} + +function ArchivePanel({ row }: { row: ArchiveRow }): JSX.Element { + const isAudio = row.response_content_type.startsWith("audio/"); + const responseUrl = `/v1/responses/${refToUrlSafe(row.escrow_ref)}/response`; + const requestUrl = `/v1/responses/${refToUrlSafe(row.escrow_ref)}/request`; + + const [requestText, setRequestText] = useState(null); + const [chatText, setChatText] = useState(null); + const [verifyResult, setVerifyResult] = useState< + | { state: "idle" } + | { state: "running" } + | { state: "ok"; computed: string } + | { state: "mismatch"; computed: string; expected: string } + | { state: "error"; message: string } + >({ state: "idle" }); + + // Always fetch the request envelope so we can display it above the response. + // It's small JSON (chat messages array OR TTS body), no streaming worry. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const resp = await fetch(requestUrl); + if (!resp.ok) throw new Error(`fetch failed: ${resp.status}`); + const text = await resp.text(); + if (!cancelled) setRequestText(text); + } catch (err) { + if (!cancelled) setRequestText(`(failed to load: ${err instanceof Error ? err.message : String(err)})`); + } + })(); + return () => { cancelled = true; }; + }, [requestUrl]); + + // For chat, fetch the JSON response so we can render the assistant text inline. + // For audio, the