buyer: response archive (SQLite + filesystem) for off-chain audit trail

The on-chain Submit tx anchors sha256(canonicalize(receipt)) so the
receipt's signature is provably tied to a specific Cardano block — but
without persisting the receipt content + the actual response bytes
nowhere can buyer or supplier produce the artefacts a dispute would
need to argue about. This commit closes that gap on the buyer side.

What's new

- buyer/src/db/schema.ts (new):
  `responses` table keyed by escrow_ref, indexes on completed_at /
  capability_id / supplier_pkh. Stores metadata + receipt JSON +
  Ed25519 signature; large blobs (request envelope, response bytes)
  live on disk per-escrow.

- buyer/src/db/archive.ts (new):
  ResponseArchive class — persistChat / persistTts / list / get /
  readRequest / readResponse. Filesystem layout under ARCHIVE_DIR:
    <txhash>_<index>/request.json   (always JSON)
    <txhash>_<index>/response.{ext} (txt/json for chat, mp3/wav/… for TTS)
  Synchronous (better-sqlite3 + fs.writeFileSync) — volume is low and
  the simplicity is worth more than async overhead.

- buyer/src/server.ts:
  - AppDeps.archive plumbed through createApp.
  - /v1/submit-prompt + /v1/submit-tts persist BEFORE returning to
    the SPA, using canonicalize() for chat (same bytes the supplier
    hashed; JSON.stringify produced different bytes → wrong sha256).
    Persist failures are logged but never abort the response — the
    on-chain receipt is still authoritative.
  - New read-only endpoints:
      GET /v1/responses?limit=…           list metadata + parsed receipts
      GET /v1/responses/:ref               single-row detail
      GET /v1/responses/:ref/request       request artefact (application/json)
      GET /v1/responses/:ref/response      response artefact (verbatim bytes)
  - URL path uses "<txhash>_<index>" (the dir-safe form) since "#"
    can't appear unencoded in URLs (fragment delimiter); server
    normalises back to canonical "<txhash>#<index>" for SQLite lookup.
  - 503 service_unavailable on /v1/responses* when boot has no
    archive (ARCHIVE_DIR unset or DB unopenable) — the rest of
    the buyer-app still works.

- buyer/src/ui/pages/TaskHistory.tsx:
  Joins indexer escrows (chain state) + /v1/responses (archived
  artefacts) by escrow_ref. Each completed lifecycle gets an
  archive panel showing:
  - Request panel (above, chronological order): human-readable
    summary — first user message for chat, `text + voice + format +
    speed` for TTS, raw JSON fallback otherwise.
  - Response panel: assistant text inline for chat, <audio controls>
    for TTS; Download response / Download request anchors.
  - "Verify hash" button — fetches the response bytes, computes
    sha256 via crypto.subtle, compares to receipt.response_hash;
    green ✓ on match, red ✗ with truncated computed/expected on
    mismatch.
  - Collapsible "Receipt + signature" with the full canonical
    receipt JSON + the supplier's Ed25519 hex sig.

- buyer/src/config.ts: + ARCHIVE_DIR env (default "./data/archive").

- buyer/Dockerfile: builder gains python3/make/g++ + the
  better-sqlite3 native-binding fetch step (mirrors indexer/Dockerfile);
  runtime image stays toolchain-free.

- deploy/testnet/docker-compose.buyer.yml: bind-mounts
  ../../data/buyer/archive (repo-root-relative, not file-relative)
  into /data/archive. ARCHIVE_DIR env points at that path.

Tests

- tests/unit/buyer-archive.test.ts (new, 14 tests): persistChat /
  persistTts unit coverage (filesystem layout, INSERT-OR-REPLACE
  semantics, content-type → extension mapping for mp3/wav/opus/aac/
  flac, list ordering + limit, single-row get/read miss-vs-hit) +
  HTTP integration (503 when disabled, happy paths returning
  metadata / request / response artefact bytes with correct
  content-types, 404 on unknown ref).
- tests/unit/buyer-ui-smoke.test.tsx: TaskHistory fetch mock now
  also handles /v1/responses (returns empty list — same code path as
  "archive disabled" production case).

45 buyer tests passing; chat + TTS lifecycle verified end-to-end on
Vector testnet; "Verify hash" button confirms sha256(response bytes)
matches receipt.response_hash for both capabilities.
This commit is contained in:
jelisaveta-talbot
2026-05-11 08:49:07 +02:00
parent fd24598186
commit 1f1a7d5a59
12 changed files with 1225 additions and 75 deletions

View File

@@ -27,6 +27,11 @@ ARG NODE_VERSION=20.19.0
FROM node:${NODE_VERSION}-alpine AS builder FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /repo 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 RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./ 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). # must install with dev deps included (default).
RUN pnpm install --frozen-lockfile --filter '@marketplace/buyer...' 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 # libsodium-wrappers-sumo (transitive via @lucid-evolution/lucid → CML) does
# `import "./libsodium-sumo.mjs"` from its wrapper, but pnpm's strict store # `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 # layout puts the .mjs in a sibling pkg, breaking the relative import. Patch

View File

@@ -15,6 +15,7 @@
"@noble/ed25519": "^2.3.0", "@noble/ed25519": "^2.3.0",
"@noble/hashes": "^1.8.0", "@noble/hashes": "^1.8.0",
"bech32": "^2.0.0", "bech32": "^2.0.0",
"better-sqlite3": "^11.9.1",
"express": "^4.21.2", "express": "^4.21.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -24,6 +25,7 @@
"@testing-library/jest-dom": "^6.4.0", "@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/better-sqlite3": "^7.6.12",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/react": "^18.3.0", "@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",

View File

@@ -15,6 +15,11 @@
* When unset, /v1/synth-speech responds 503 (the * When unset, /v1/synth-speech responds 503 (the
* capability stays disabled — the rest of the * capability stays disabled — the rest of the
* buyer-app boots normally). * 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}$/; const HEX64_RE = /^[0-9a-fA-F]{64}$/;
@@ -28,6 +33,7 @@ export interface BuyerConfig {
ogmiosUrl: string; ogmiosUrl: string;
liveChain: boolean; liveChain: boolean;
ttsPiperBaseUrl: string; ttsPiperBaseUrl: string;
archiveDir: string;
} }
function requireField(env: Record<string, string | undefined>, name: string): string { function requireField(env: Record<string, string | undefined>, name: string): string {
@@ -80,5 +86,11 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
// shape here; an unreachable URL surfaces as a clear 502 from the proxy. // shape here; an unreachable URL surfaces as a clear 502 from the proxy.
const ttsPiperBaseUrl = env.TTS_PIPER_BASE_URL ?? ""; 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 };
} }

241
buyer/src/db/archive.ts Normal file
View File

@@ -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
* <txhash>_<index>/request.json — what the buyer sent
* <txhash>_<index>/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 "<txhash>#<index>" 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<string, unknown>;
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<string, unknown>;
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<string, unknown>);
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<string, unknown>);
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 "<txhash>#<index>" 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();
}
}

53
buyer/src/db/schema.ts Normal file
View File

@@ -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 (
-- "<txhash>#<index>" 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>/.
-- 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);
`;

View File

@@ -15,6 +15,7 @@ import { loadConfig } from "./config.js";
import { createApp } from "./server.js"; import { createApp } from "./server.js";
import { buildChainProvider } from "./chain.js"; import { buildChainProvider } from "./chain.js";
import { Marketplace, MemoryTaskHistoryStore } from "./sdk/index.js"; import { Marketplace, MemoryTaskHistoryStore } from "./sdk/index.js";
import { ResponseArchive } from "./db/archive.js";
import type { WalletKey } from "@marketplace/shared/tx"; import type { WalletKey } from "@marketplace/shared/tx";
// Wire ed25519 sha512 hook (idempotent — same as receipt/sign.ts). // Wire ed25519 sha512 hook (idempotent — same as receipt/sign.ts).
@@ -74,6 +75,22 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
}); });
const distPath = resolve(process.cwd(), "buyer/dist"); 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({ const app = createApp({
distPath, distPath,
chain, chain,
@@ -81,6 +98,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
indexerUrl: config.indexerUrl, indexerUrl: config.indexerUrl,
marketplace, marketplace,
ttsPiperBaseUrl: config.ttsPiperBaseUrl, ttsPiperBaseUrl: config.ttsPiperBaseUrl,
archive,
}); });
const server = app.listen(config.port, () => { const server = app.listen(config.port, () => {

View File

@@ -25,7 +25,9 @@ import type { ChainProvider } from "@marketplace/shared/chain";
import type { WalletKey } from "@marketplace/shared/tx"; import type { WalletKey } from "@marketplace/shared/tx";
import type { Marketplace } from "./sdk/Marketplace.js"; import type { Marketplace } from "./sdk/Marketplace.js";
import type { ChatMessage } from "@marketplace/shared/tx"; import type { ChatMessage } from "@marketplace/shared/tx";
import { canonicalize } from "@marketplace/shared/cbor";
import { runAccept } from "./cli/acceptFlow.js"; import { runAccept } from "./cli/acceptFlow.js";
import type { ResponseArchive } from "./db/archive.js";
const ESCROW_REF_RE = /^([0-9a-f]{64})#(\d+)$/; 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 * `/v1/synth-speech` proxies to. Falls back to the public Vector-testnet
* host when not set. Endpoint stays disabled (503) when this is empty. */ * host when not set. Endpoint stays disabled (503) when this is empty. */
ttsPiperBaseUrl?: string; 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. */ /** Injectable fetch for tests. Defaults to globalThis.fetch. */
fetchImpl?: typeof globalThis.fetch; fetchImpl?: typeof globalThis.fetch;
} }
@@ -297,13 +305,49 @@ export function createApp(deps: AppDeps): Express {
payment_lovelace, payment_lovelace,
max_output_tokens, 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<string, unknown>,
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 // Wrap the assistant text in OpenAI-shape `choices[0].message` so
// downstream clients (PromptForm) can unpack uniformly. // downstream clients (PromptForm) can unpack uniformly.
return res.status(200).json({ return res.status(200).json({
choices: [{ index: 0, message: { role: "assistant", content: result.response }, finish_reason: "stop" }], choices: [{ index: 0, message: { role: "assistant", content: result.response }, finish_reason: "stop" }],
receipt: result.receipt, receipt: result.receipt,
receipt_signature: result.receiptSignature, receipt_signature: result.receiptSignature,
escrow_ref: `${result.escrowRef.txHash}#${result.escrowRef.index}`, escrow_ref: escrowRefStr,
}); });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
@@ -468,6 +512,35 @@ export function createApp(deps: AppDeps): Express {
speed, speed,
payment_lovelace, 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<string, unknown>,
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({ return res.status(200).json({
audio_b64: result.audio_b64, audio_b64: result.audio_b64,
format: result.format, format: result.format,
@@ -475,7 +548,7 @@ export function createApp(deps: AppDeps): Express {
byte_length: result.byte_length, byte_length: result.byte_length,
receipt: result.receipt, receipt: result.receipt,
receipt_signature: result.receiptSignature, receipt_signature: result.receiptSignature,
escrow_ref: `${result.escrowRef.txHash}#${result.escrowRef.index}`, escrow_ref: escrowRefStr,
}); });
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(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 "<txhash>#<index>" form.
// URL paths can't carry "#" (fragment delimiter) so the API uses the
// dir-safe "<txhash>_<index>" 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>_<int>" (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) { if (deps.distPath) {
let isDir = false; let isDir = false;
try { try {

View File

@@ -1,19 +1,23 @@
/** /**
* buyer/src/ui/pages/TaskHistory.tsx — full lifecycle history for this buyer. * 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 * Two data sources joined client-side:
* pkh (proxied through the buyer-app's /v1/indexer/* passthrough), group by * 1. /v1/indexer/escrows — chain state of every lifecycle this buyer
* `posted_at` (which is preserved across state transitions by the * participated in (Open → Claimed → Submitted → Accepted/etc).
* `expect_unchanged_modulo_state` validator invariant), and render one card * 2. /v1/responses — buyer-app's persistent archive (request +
* per lifecycle. * 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 * Match key: lifecycle's first row (the original PostEscrow output ref) ==
* LocalStorageTaskHistoryStore that was written by `marketplace.submitPrompt`. * archive row's `escrow_ref`. Lifecycles that completed get inline response
* After UX-2b moved submitPrompt server-side, the browser SDK no longer * rendering (audio player for TTS, JSON pretty-print for chat) plus a
* writes anything, so localStorage was always empty. The chain itself is the * Verify-hash button that computes sha256 over the response bytes and
* canonical history; rendering directly from indexer rows means a fresh * compares to `receipt.response_hash`.
* browser session immediately sees every prior lifecycle this buyer has *
* participated in, on any device. * 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"; import { useEffect, useState, useMemo } from "react";
@@ -36,7 +40,31 @@ interface IndexerEscrowRow {
created_slot: number; 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 { interface Lifecycle {
posted_at: number; posted_at: number;
supplier_pkh: string; supplier_pkh: string;
@@ -46,8 +74,11 @@ interface Lifecycle {
supplier_bond_lovelace: string; supplier_bond_lovelace: string;
advert_ref: string; advert_ref: string;
prompt_hash: string; prompt_hash: string;
rows: IndexerEscrowRow[]; // sorted ascending by created_slot rows: IndexerEscrowRow[];
currentState: string; // = rows[rows.length-1].state 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<string, number> = { const STATE_PRIORITY: Record<string, number> = {
@@ -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<string, ArchiveRow>,
): Lifecycle[] {
const buckets = new Map<string, IndexerEscrowRow[]>(); const buckets = new Map<string, IndexerEscrowRow[]>();
for (const r of rows) { 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 key = `${r.posted_at}:${r.supplier_pkh}`;
const arr = buckets.get(key); const arr = buckets.get(key);
if (arr) arr.push(r); if (arr) arr.push(r);
@@ -109,10 +144,8 @@ function groupByLifecycle(rows: IndexerEscrowRow[]): Lifecycle[] {
const out: Lifecycle[] = []; const out: Lifecycle[] = [];
for (const [, lcRows] of buckets) { for (const [, lcRows] of buckets) {
lcRows.sort((a, b) => a.created_slot - b.created_slot); 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]; 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) => const top = lcRows.reduce((acc, r) =>
(STATE_PRIORITY[r.state] ?? 0) > (STATE_PRIORITY[acc.state] ?? 0) ? r : acc, (STATE_PRIORITY[r.state] ?? 0) > (STATE_PRIORITY[acc.state] ?? 0) ? r : acc,
last); last);
@@ -127,17 +160,48 @@ function groupByLifecycle(rows: IndexerEscrowRow[]): Lifecycle[] {
prompt_hash: last.prompt_hash, prompt_hash: last.prompt_hash,
rows: lcRows, rows: lcRows,
currentState: top.state, currentState: top.state,
archive: archive.get(first.utxo_ref) ?? null,
}); });
} }
// Newest first.
out.sort((a, b) => b.posted_at - a.posted_at); out.sort((a, b) => b.posted_at - a.posted_at);
return out; return out;
} }
// SubtleCrypto sha256 → 32-byte hex string.
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
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() { export default function TaskHistory() {
const marketplace = useMarketplace(); const marketplace = useMarketplace();
const buyerPkh = marketplace.getWalletKey().pubKeyHash; const buyerPkh = marketplace.getWalletKey().pubKeyHash;
const [rows, setRows] = useState<IndexerEscrowRow[] | null>(null); const [rows, setRows] = useState<IndexerEscrowRow[] | null>(null);
const [archive, setArchive] = useState<Map<string, ArchiveRow>>(new Map());
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const load = async (): Promise<void> => { const load = async (): Promise<void> => {
@@ -148,12 +212,27 @@ export default function TaskHistory() {
} }
setError(null); setError(null);
try { try {
const resp = await fetch(`/v1/indexer/escrows?buyer=${buyerPkh}`); const [escrowsResp, archResp] = await Promise.all([
if (!resp.ok) { fetch(`/v1/indexer/escrows?buyer=${buyerPkh}`),
throw new Error(`indexer responded ${resp.status} ${resp.statusText}`); 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 : []); 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<string, ArchiveRow>();
for (const r of archBody.responses ?? []) m.set(r.escrow_ref, r);
setArchive(m);
} else {
setArchive(new Map());
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
setRows([]); setRows([]);
@@ -163,8 +242,8 @@ export default function TaskHistory() {
useEffect(() => { void load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [buyerPkh]); useEffect(() => { void load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [buyerPkh]);
const lifecycles = useMemo<Lifecycle[]>( const lifecycles = useMemo<Lifecycle[]>(
() => (rows === null ? [] : groupByLifecycle(rows)), () => (rows === null ? [] : groupByLifecycle(rows, archive)),
[rows], [rows, archive],
); );
return ( return (
@@ -197,52 +276,233 @@ export default function TaskHistory() {
) : ( ) : (
<ul className="space-y-3"> <ul className="space-y-3">
{lifecycles.map((lc) => ( {lifecycles.map((lc) => (
<li <LifecycleCard key={`${lc.posted_at}:${lc.supplier_pkh}`} lc={lc} />
key={`${lc.posted_at}:${lc.supplier_pkh}`}
data-testid="task-row"
className="rounded-md bg-white p-4 shadow-sm"
>
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
<div className="text-xs font-mono text-gray-500">
{fmtTime(lc.posted_at)}
</div>
<span
className={`rounded px-2 py-0.5 text-xs font-medium ${statePill(lc.currentState)}`}
>
{lc.currentState}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-700 mb-2">
<span className="font-medium">{lc.capability_id}</span>
<span className="text-gray-500">·</span>
<span>{fmtAda(lc.payment_lovelace)} payment</span>
<span className="text-gray-500">·</span>
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
<span className="text-gray-500">·</span>
<span className="font-mono text-xs text-gray-500">
supplier {lc.supplier_pkh.slice(0, 12)}…
</span>
</div>
<div className="space-y-1 border-t border-gray-100 pt-2">
{lc.rows.map((r) => (
<div
key={r.utxo_ref}
className="flex flex-wrap items-center gap-x-3 text-xs"
>
<span
className={`rounded px-1.5 py-0.5 font-medium ${statePill(r.state)}`}
>
{r.state}
</span>
<span className="font-mono text-gray-600">{txHashFromRef(r.utxo_ref)}</span>
<span className="font-mono text-gray-400">slot {r.created_slot}</span>
</div>
))}
</div>
</li>
))} ))}
</ul> </ul>
)} )}
</div> </div>
); );
} }
function LifecycleCard({ lc }: { lc: Lifecycle }): JSX.Element {
return (
<li data-testid="task-row" className="rounded-md bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2 mb-2">
<div className="text-xs font-mono text-gray-500">
{fmtTime(lc.posted_at)}
</div>
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statePill(lc.currentState)}`}>
{lc.currentState}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-700 mb-2">
<span className="font-medium">{lc.capability_id}</span>
{lc.archive && (
<>
<span className="text-gray-500">·</span>
<span className="text-xs text-gray-600">model: {lc.archive.model}</span>
</>
)}
<span className="text-gray-500">·</span>
<span>{fmtAda(lc.payment_lovelace)} payment</span>
<span className="text-gray-500">·</span>
<span>{fmtAda(lc.buyer_bond_lovelace)} bond</span>
<span className="text-gray-500">·</span>
<span className="font-mono text-xs text-gray-500">
supplier {lc.supplier_pkh.slice(0, 12)}…
</span>
</div>
{lc.archive && <ArchivePanel row={lc.archive} />}
<div className="space-y-1 border-t border-gray-100 pt-2 mt-3">
{lc.rows.map((r) => (
<div key={r.utxo_ref} className="flex flex-wrap items-center gap-x-3 text-xs">
<span className={`rounded px-1.5 py-0.5 font-medium ${statePill(r.state)}`}>{r.state}</span>
<span className="font-mono text-gray-600">{txHashFromRef(r.utxo_ref)}</span>
<span className="font-mono text-gray-400">slot {r.created_slot}</span>
</div>
))}
</div>
</li>
);
}
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<string | null>(null);
const [chatText, setChatText] = useState<string | null>(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 <audio> element streams from the URL directly.
useEffect(() => {
if (isAudio) return;
let cancelled = false;
(async () => {
try {
const resp = await fetch(responseUrl);
if (!resp.ok) throw new Error(`fetch failed: ${resp.status}`);
const text = await resp.text();
if (!cancelled) setChatText(text);
} catch (err) {
if (!cancelled) setChatText(`(failed to load: ${err instanceof Error ? err.message : String(err)})`);
}
})();
return () => { cancelled = true; };
}, [responseUrl, isAudio]);
const onVerify = async (): Promise<void> => {
setVerifyResult({ state: "running" });
try {
const r = await verifyResponseHash(row.escrow_ref, row.receipt.response_hash);
if (r.ok) setVerifyResult({ state: "ok", computed: r.computed });
else setVerifyResult({ state: "mismatch", computed: r.computed, expected: row.receipt.response_hash });
} catch (err) {
setVerifyResult({ state: "error", message: err instanceof Error ? err.message : String(err) });
}
};
// Try to extract a human-readable summary from the request JSON:
// chat → first user message's content
// tts → the `text` field
// anything else falls back to raw JSON
const requestSummary = (() => {
if (requestText === null) return null;
try {
const obj = JSON.parse(requestText) as {
messages?: Array<{ role?: string; content?: string }>;
text?: string;
voice?: string;
format?: string;
speed?: number;
};
if (obj.messages && Array.isArray(obj.messages)) {
const userMsg = obj.messages.find((m) => m?.role === "user") ?? obj.messages[0];
return userMsg?.content ?? requestText;
}
if (typeof obj.text === "string") {
const knobs = [obj.voice, obj.format, obj.speed != null ? `speed ${obj.speed}` : null]
.filter(Boolean).join(" · ");
return knobs ? `${obj.text}\n\n[ ${knobs} ]` : obj.text;
}
return requestText;
} catch {
return requestText;
}
})();
return (
<div className="rounded border border-gray-200 bg-gray-50 p-3 mt-2 space-y-2" data-testid="archive-panel">
{/* Request artefact (above response — chronological order) */}
<div>
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">Request</div>
{requestSummary === null ? (
<p className="text-sm text-gray-500">loading…</p>
) : (
<pre className="whitespace-pre-wrap break-words rounded bg-white p-2 text-sm text-gray-800 max-h-48 overflow-auto">
{requestSummary}
</pre>
)}
</div>
{/* Response artefact */}
<div>
<div className="text-xs uppercase tracking-wide text-gray-500 mb-1">Response</div>
{isAudio ? (
<audio controls src={responseUrl} className="w-full" />
) : chatText === null ? (
<p className="text-sm text-gray-500">loading…</p>
) : (
<pre className="whitespace-pre-wrap break-words rounded bg-white p-2 text-sm text-gray-800 max-h-64 overflow-auto">
{(() => {
try {
const parsed = JSON.parse(chatText) as { content?: string };
return parsed.content ?? chatText;
} catch {
return chatText;
}
})()}
</pre>
)}
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-600 mt-1">
<span>{(row.response_byte_length / 1024).toFixed(1)} KB · {row.response_content_type}</span>
<a
href={responseUrl}
download
className="rounded bg-gray-700 px-2 py-0.5 text-white hover:bg-gray-800"
>
Download response
</a>
<a
href={requestUrl}
download
className="rounded bg-gray-200 px-2 py-0.5 text-gray-700 hover:bg-gray-300"
>
Download request
</a>
</div>
</div>
{/* Receipt verification */}
<div className="border-t border-gray-200 pt-2">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => void onVerify()}
disabled={verifyResult.state === "running"}
className="rounded bg-indigo-600 px-3 py-1 text-xs font-medium text-white hover:bg-indigo-700 disabled:bg-gray-400"
data-testid="archive-verify"
>
{verifyResult.state === "running" ? "Verifying…" : "Verify hash"}
</button>
{verifyResult.state === "ok" && (
<span className="text-xs text-green-700">
✓ sha256 matches receipt.response_hash
</span>
)}
{verifyResult.state === "mismatch" && (
<span className="text-xs text-red-700">
✗ mismatch (computed {verifyResult.computed.slice(0, 12)}… vs expected {verifyResult.expected.slice(0, 12)}…)
</span>
)}
{verifyResult.state === "error" && (
<span className="text-xs text-red-700">error: {verifyResult.message}</span>
)}
</div>
<details className="mt-2 text-xs text-gray-600">
<summary className="cursor-pointer">Receipt + signature</summary>
<pre className="mt-1 rounded bg-white p-2 font-mono text-[10px] overflow-auto">
{JSON.stringify({ receipt: row.receipt, signature: row.receipt_signature }, null, 2)}
</pre>
</details>
</div>
</div>
);
}

View File

@@ -40,6 +40,17 @@ services:
# apex-simulation). When this is unset, /v1/synth-speech responds 503 # apex-simulation). When this is unset, /v1/synth-speech responds 503
# and the SPA's PiperTTSForm shows a clear error. # and the SPA's PiperTTSForm shows a clear error.
TTS_PIPER_BASE_URL: "https://tts-piper.vector.testnet.apexfusion.org" TTS_PIPER_BASE_URL: "https://tts-piper.vector.testnet.apexfusion.org"
# Response archive — SQLite metadata + per-escrow artefact files for
# off-chain dispute resolution. Host-bind-mounted below so it survives
# container rebuilds and an operator can `ls`/back-up directly.
ARCHIVE_DIR: /data/archive
volumes:
# Bind-mount path is repo-root-relative (../../) so the on-disk
# archive lives at $REPO_ROOT/data/buyer/archive — same convention
# we use elsewhere. Without this, docker-compose's default would
# place the volume at deploy/testnet/data/... (relative to THIS
# file), which is harder to find and back up.
- ../../data/buyer/archive:/data/archive
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8070/healthz >/dev/null || exit 1"] test: ["CMD-SHELL", "wget -qO- http://localhost:8070/healthz >/dev/null || exit 1"]
interval: 20s interval: 20s

6
pnpm-lock.yaml generated
View File

@@ -89,6 +89,9 @@ importers:
bech32: bech32:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
better-sqlite3:
specifier: ^11.9.1
version: 11.10.0
express: express:
specifier: ^4.21.2 specifier: ^4.21.2
version: 4.22.1 version: 4.22.1
@@ -111,6 +114,9 @@ importers:
'@testing-library/user-event': '@testing-library/user-event':
specifier: ^14.5.2 specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1) version: 14.6.1(@testing-library/dom@10.4.1)
'@types/better-sqlite3':
specifier: ^7.6.12
version: 7.6.13
'@types/express': '@types/express':
specifier: ^5.0.3 specifier: ^5.0.3
version: 5.0.6 version: 5.0.6

View File

@@ -0,0 +1,349 @@
/**
* buyer-archive.test.ts — coverage for the response archive (SQLite +
* filesystem) added in M2-AUDIT.
*
* Two layers:
* 1. ResponseArchive in isolation (persistChat / persistTts / list / get
* / readRequest / readResponse, plus filesystem layout).
* 2. The /v1/responses* HTTP endpoints exposed by createApp when an
* archive is injected (and the 503 fallback when it isn't).
*
* Uses a per-test temp dir so tests are independent and don't pollute each
* other's SQLite WAL. better-sqlite3 is synchronous — no flakey waits.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { ResponseArchive } from "../../buyer/src/db/archive.js";
import { createApp } from "../../buyer/src/server.js";
let dir: string;
let archive: ResponseArchive;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "archive-test-"));
archive = new ResponseArchive(dir);
});
afterEach(() => {
archive.close();
rmSync(dir, { recursive: true, force: true });
});
const SAMPLE_REF = `${"a".repeat(64)}#0`;
function sampleReceipt() {
return {
prompt_hash: "b".repeat(64),
response_hash: "c".repeat(64),
model: "qwen2.5:0.5b",
prompt_tokens: 12,
completion_tokens: 48,
wallclock_ms: 3200,
supplier_pkh: "d".repeat(56),
escrow_ref: SAMPLE_REF,
};
}
// ─── ResponseArchive in isolation ────────────────────────────────────────
describe("ResponseArchive — persistChat", () => {
it("writes request.json + response.json + DB row", () => {
const row = archive.persistChat({
escrow_ref: SAMPLE_REF,
posted_at: 1_700_000_000_000,
capability_id: "llm.text.generate.v1",
supplier_pkh: "d".repeat(56),
model: "qwen2.5:0.5b",
payment_lovelace: "2000000",
request_messages: [{ role: "user", content: "hi" }],
response_canonical: '{"role":"assistant","content":"hello there"}',
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
});
expect(row.escrow_ref).toBe(SAMPLE_REF);
expect(row.response_content_type).toBe("application/json");
expect(row.response_byte_length).toBe(44);
// Filesystem: <dir>/<txhash>_0/{request.json,response.json}
const recDir = join(dir, `${"a".repeat(64)}_0`);
expect(existsSync(join(recDir, "request.json"))).toBe(true);
expect(existsSync(join(recDir, "response.json"))).toBe(true);
// Response file matches what we passed in (byte-exact — important for
// sha256 verification against receipt.response_hash).
expect(readFileSync(join(recDir, "response.json"), "utf8"))
.toBe('{"role":"assistant","content":"hello there"}');
});
it("INSERT OR REPLACE on duplicate escrow_ref", () => {
const baseParams = {
escrow_ref: SAMPLE_REF,
posted_at: 1_700_000_000_000,
capability_id: "llm.text.generate.v1",
supplier_pkh: "d".repeat(56),
model: "qwen2.5:0.5b",
payment_lovelace: "2000000",
request_messages: [{ role: "user", content: "first" }],
response_canonical: '{"role":"assistant","content":"first"}',
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
};
archive.persistChat(baseParams);
archive.persistChat({
...baseParams,
response_canonical: '{"role":"assistant","content":"second"}',
});
expect(archive.list(10).length).toBe(1);
const r = archive.readResponse(SAMPLE_REF);
expect(r?.bytes.toString("utf8")).toBe('{"role":"assistant","content":"second"}');
});
});
describe("ResponseArchive — persistTts", () => {
it("writes audio bytes verbatim under response.<ext> + DB row", () => {
const audio = new Uint8Array([0xff, 0xfb, 0x90, 0x44, 0x00, 0x00]);
const row = archive.persistTts({
escrow_ref: SAMPLE_REF,
posted_at: 1_700_000_000_000,
capability_id: "audio.synthesize.piper.v1",
supplier_pkh: "d".repeat(56),
model: "piper-en-us-multi",
payment_lovelace: "2000000",
request_envelope: { text: "hello", voice: "nova", format: "mp3", speed: 1.0 },
response_audio: audio,
response_content_type: "audio/mpeg",
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
});
expect(row.response_filename).toBe("response.mp3");
expect(row.response_content_type).toBe("audio/mpeg");
expect(row.response_byte_length).toBe(6);
const r = archive.readResponse(SAMPLE_REF);
expect(r?.bytes).toEqual(Buffer.from(audio));
expect(r?.contentType).toBe("audio/mpeg");
expect(r?.filename).toBe("response.mp3");
});
it("picks the right extension for various audio content-types", () => {
const cases: Array<[string, string]> = [
["audio/mpeg", "response.mp3"],
["audio/wav", "response.wav"],
["audio/x-wav", "response.wav"],
["audio/opus", "response.opus"],
["audio/flac", "response.flac"],
["audio/aac", "response.aac"],
];
for (const [ct, expectedFilename] of cases) {
const ref = `${ct.replace(/\W/g, "0").padEnd(64, "0").slice(0, 64)}#0`;
const row = archive.persistTts({
escrow_ref: ref,
posted_at: 0,
capability_id: "audio.synthesize.piper.v1",
supplier_pkh: "d".repeat(56),
model: "x",
payment_lovelace: "1",
request_envelope: {},
response_audio: new Uint8Array([1, 2, 3, 4]),
response_content_type: ct,
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
});
expect(row.response_filename).toBe(expectedFilename);
}
});
});
describe("ResponseArchive — read helpers", () => {
it("get() returns null on miss", () => {
expect(archive.get("not-a-real-ref")).toBeNull();
expect(archive.readRequest("not-a-real-ref")).toBeNull();
expect(archive.readResponse("not-a-real-ref")).toBeNull();
});
it("list() returns newest first", async () => {
archive.persistChat({
escrow_ref: `${"a".repeat(64)}#0`,
posted_at: 0,
capability_id: "llm.text.generate.v1",
supplier_pkh: "d".repeat(56),
model: "x",
payment_lovelace: "1",
request_messages: [],
response_canonical: "first",
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
});
// Force a different completed_at (better-sqlite3 + Date.now gives ms
// resolution; sleep a tick).
await new Promise<void>((r) => setTimeout(r, 5));
archive.persistChat({
escrow_ref: `${"b".repeat(64)}#0`,
posted_at: 0,
capability_id: "llm.text.generate.v1",
supplier_pkh: "d".repeat(56),
model: "x",
payment_lovelace: "1",
request_messages: [],
response_canonical: "second",
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
});
const rows = archive.list(10);
expect(rows.length).toBe(2);
expect(rows[0].escrow_ref.startsWith("b")).toBe(true);
expect(rows[1].escrow_ref.startsWith("a")).toBe(true);
});
it("respects limit", () => {
for (let i = 0; i < 5; i++) {
archive.persistChat({
escrow_ref: `${i.toString().repeat(64).slice(0, 64)}#0`,
posted_at: 0,
capability_id: "llm.text.generate.v1",
supplier_pkh: "d".repeat(56),
model: "x",
payment_lovelace: "1",
request_messages: [],
response_canonical: "x",
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
});
}
expect(archive.list(3).length).toBe(3);
});
});
// ─── HTTP endpoints (server.ts wiring) ───────────────────────────────────
describe("GET /v1/responses* — disabled (no archive injected)", () => {
it("503 service_unavailable on list endpoint", async () => {
const app = createApp({});
const { default: request } = await import("supertest");
const res = await request(app).get("/v1/responses");
expect(res.status).toBe(503);
expect(res.body.error).toBe("service_unavailable");
});
it("503 also on detail / artefact endpoints", async () => {
const app = createApp({});
const { default: request } = await import("supertest");
const ref = SAMPLE_REF;
for (const path of [
`/v1/responses/${ref}`,
`/v1/responses/${ref}/request`,
`/v1/responses/${ref}/response`,
]) {
const res = await request(app).get(path);
expect(res.status).toBe(503);
}
});
});
describe("GET /v1/responses* — happy paths", () => {
it("list returns persisted rows newest-first with parsed receipt", async () => {
archive.persistChat({
escrow_ref: SAMPLE_REF,
posted_at: 1_700_000_000_000,
capability_id: "llm.text.generate.v1",
supplier_pkh: "d".repeat(56),
model: "qwen2.5:0.5b",
payment_lovelace: "2000000",
request_messages: [{ role: "user", content: "hi" }],
response_canonical: '{"role":"assistant","content":"hello"}',
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
});
const app = createApp({ archive });
const { default: request } = await import("supertest");
const res = await request(app).get("/v1/responses");
expect(res.status).toBe(200);
expect(Array.isArray(res.body.responses)).toBe(true);
expect(res.body.responses.length).toBe(1);
const r = res.body.responses[0];
expect(r.escrow_ref).toBe(SAMPLE_REF);
expect(r.receipt).toMatchObject({ model: "qwen2.5:0.5b", supplier_pkh: "d".repeat(56) });
expect(r.receipt_signature).toBe("f".repeat(128));
});
it("detail returns metadata + receipt for a known escrow", async () => {
archive.persistTts({
escrow_ref: SAMPLE_REF,
posted_at: 1_700_000_000_000,
capability_id: "audio.synthesize.piper.v1",
supplier_pkh: "d".repeat(56),
model: "piper-en-us-multi",
payment_lovelace: "2000000",
request_envelope: { text: "hi", voice: "nova", format: "mp3", speed: 1.0 },
response_audio: new Uint8Array([1, 2, 3, 4]),
response_content_type: "audio/mpeg",
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
});
const app = createApp({ archive });
const { default: request } = await import("supertest");
const res = await request(app).get(`/v1/responses/${SAMPLE_REF.replace("#", "_")}`);
expect(res.status).toBe(200);
expect(res.body.escrow_ref).toBe(SAMPLE_REF);
expect(res.body.response_filename).toBe("response.mp3");
expect(res.body.response_content_type).toBe("audio/mpeg");
expect(res.body.response_byte_length).toBe(4);
});
it("404 on unknown escrow_ref", async () => {
const app = createApp({ archive });
const { default: request } = await import("supertest");
const ghost = `${"e".repeat(64)}#3`;
const res = await request(app).get(`/v1/responses/${ghost.replace("#", "_")}`);
expect(res.status).toBe(404);
expect(res.body.error).toBe("not_found");
});
it("/request returns the JSON envelope as application/json", async () => {
archive.persistChat({
escrow_ref: SAMPLE_REF,
posted_at: 0,
capability_id: "llm.text.generate.v1",
supplier_pkh: "d".repeat(56),
model: "x",
payment_lovelace: "1",
request_messages: [{ role: "user", content: "hello world" }],
response_canonical: '{"role":"assistant","content":"hi"}',
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
});
const app = createApp({ archive });
const { default: request } = await import("supertest");
const res = await request(app).get(`/v1/responses/${SAMPLE_REF.replace("#", "_")}/request`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/application\/json/);
expect(JSON.parse(res.text).messages[0].content).toBe("hello world");
});
it("/response returns audio bytes with upstream Content-Type", async () => {
const audio = new Uint8Array([0xff, 0xfb, 0x90, 0x44, 0x00, 0x00, 0x00, 0x01]);
archive.persistTts({
escrow_ref: SAMPLE_REF,
posted_at: 0,
capability_id: "audio.synthesize.piper.v1",
supplier_pkh: "d".repeat(56),
model: "x",
payment_lovelace: "1",
request_envelope: {},
response_audio: audio,
response_content_type: "audio/mpeg",
receipt: sampleReceipt(),
receipt_signature: "f".repeat(128),
});
const app = createApp({ archive });
const { default: request } = await import("supertest");
const res = await request(app).get(`/v1/responses/${SAMPLE_REF.replace("#", "_")}/response`);
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch(/audio\/mpeg/);
expect(res.body).toEqual(Buffer.from(audio));
expect(res.headers["content-disposition"]).toContain("response.mp3");
});
});

View File

@@ -279,6 +279,16 @@ describe("<TaskHistory /> smoke", () => {
]; ];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : (input instanceof URL ? input.toString() : input.url); const url = typeof input === "string" ? input : (input instanceof URL ? input.toString() : input.url);
// TaskHistory now also fetches /v1/responses (the buyer-app's archive
// index) and joins by escrow_ref. Empty list is a valid response that
// means "archive disabled / no records yet" and exercises the same
// "no archive payload to render inline" code path as production.
if (url.includes("/v1/responses")) {
return new Response(JSON.stringify({ responses: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (!url.includes(`/v1/indexer/escrows?buyer=${buyerPkh}`)) { if (!url.includes(`/v1/indexer/escrows?buyer=${buyerPkh}`)) {
throw new Error(`unexpected fetch: ${url}`); throw new Error(`unexpected fetch: ${url}`);
} }