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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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.
|
||||
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
241
buyer/src/db/archive.ts
Normal 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
53
buyer/src/db/schema.ts
Normal 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);
|
||||
`;
|
||||
@@ -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<string, string | undefined>): 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<string, string | undefined>): Promise<
|
||||
indexerUrl: config.indexerUrl,
|
||||
marketplace,
|
||||
ttsPiperBaseUrl: config.ttsPiperBaseUrl,
|
||||
archive,
|
||||
});
|
||||
|
||||
const server = app.listen(config.port, () => {
|
||||
|
||||
@@ -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<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
|
||||
// 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<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({
|
||||
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 "<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) {
|
||||
let isDir = false;
|
||||
try {
|
||||
|
||||
@@ -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<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[]>();
|
||||
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<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() {
|
||||
const marketplace = useMarketplace();
|
||||
const buyerPkh = marketplace.getWalletKey().pubKeyHash;
|
||||
const [rows, setRows] = useState<IndexerEscrowRow[] | null>(null);
|
||||
const [archive, setArchive] = useState<Map<string, ArchiveRow>>(new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async (): Promise<void> => {
|
||||
@@ -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<string, ArchiveRow>();
|
||||
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<Lifecycle[]>(
|
||||
() => (rows === null ? [] : groupByLifecycle(rows)),
|
||||
[rows],
|
||||
() => (rows === null ? [] : groupByLifecycle(rows, archive)),
|
||||
[rows, archive],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -197,23 +276,33 @@ export default function TaskHistory() {
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{lifecycles.map((lc) => (
|
||||
<li
|
||||
key={`${lc.posted_at}:${lc.supplier_pkh}`}
|
||||
data-testid="task-row"
|
||||
className="rounded-md bg-white p-4 shadow-sm"
|
||||
>
|
||||
<LifecycleCard key={`${lc.posted_at}:${lc.supplier_pkh}`} lc={lc} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</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)}`}
|
||||
>
|
||||
<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>
|
||||
@@ -223,26 +312,197 @@ export default function TaskHistory() {
|
||||
supplier {lc.supplier_pkh.slice(0, 12)}…
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 border-t border-gray-100 pt-2">
|
||||
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,17 @@ services:
|
||||
# apex-simulation). When this is unset, /v1/synth-speech responds 503
|
||||
# and the SPA's PiperTTSForm shows a clear error.
|
||||
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:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8070/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -89,6 +89,9 @@ importers:
|
||||
bech32:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
better-sqlite3:
|
||||
specifier: ^11.9.1
|
||||
version: 11.10.0
|
||||
express:
|
||||
specifier: ^4.21.2
|
||||
version: 4.22.1
|
||||
@@ -111,6 +114,9 @@ importers:
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.5.2
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
'@types/better-sqlite3':
|
||||
specifier: ^7.6.12
|
||||
version: 7.6.13
|
||||
'@types/express':
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.6
|
||||
|
||||
349
tests/unit/buyer-archive.test.ts
Normal file
349
tests/unit/buyer-archive.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -279,6 +279,16 @@ describe("<TaskHistory /> smoke", () => {
|
||||
];
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | 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}`)) {
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user