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
|
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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
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 { 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, () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,23 +276,33 @@ 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"
|
</ul>
|
||||||
className="rounded-md bg-white p-4 shadow-sm"
|
)}
|
||||||
>
|
</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="flex flex-wrap items-baseline justify-between gap-2 mb-2">
|
||||||
<div className="text-xs font-mono text-gray-500">
|
<div className="text-xs font-mono text-gray-500">
|
||||||
{fmtTime(lc.posted_at)}
|
{fmtTime(lc.posted_at)}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statePill(lc.currentState)}`}>
|
||||||
className={`rounded px-2 py-0.5 text-xs font-medium ${statePill(lc.currentState)}`}
|
|
||||||
>
|
|
||||||
{lc.currentState}
|
{lc.currentState}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-700 mb-2">
|
<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="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 className="text-gray-500">·</span>
|
||||||
<span>{fmtAda(lc.payment_lovelace)} payment</span>
|
<span>{fmtAda(lc.payment_lovelace)} payment</span>
|
||||||
<span className="text-gray-500">·</span>
|
<span className="text-gray-500">·</span>
|
||||||
@@ -223,26 +312,197 @@ export default function TaskHistory() {
|
|||||||
supplier {lc.supplier_pkh.slice(0, 12)}…
|
supplier {lc.supplier_pkh.slice(0, 12)}…
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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) => (
|
{lc.rows.map((r) => (
|
||||||
<div
|
<div key={r.utxo_ref} className="flex flex-wrap items-center gap-x-3 text-xs">
|
||||||
key={r.utxo_ref}
|
<span className={`rounded px-1.5 py-0.5 font-medium ${statePill(r.state)}`}>{r.state}</span>
|
||||||
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-600">{txHashFromRef(r.utxo_ref)}</span>
|
||||||
<span className="font-mono text-gray-400">slot {r.created_slot}</span>
|
<span className="font-mono text-gray-400">slot {r.created_slot}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</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>
|
</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
|
# 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
6
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
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) => {
|
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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user