Files
agents-marketplace/tests/unit/buyer-archive.test.ts
jelisaveta-talbot 1f1a7d5a59 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.
2026-05-11 08:49:07 +02:00

350 lines
13 KiB
TypeScript

/**
* 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");
});
});