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.
86 lines
3.2 KiB
YAML
86 lines
3.2 KiB
YAML
# deploy/testnet/docker-compose.buyer.yml — Buyer (Express + Vite UI) — TESTNET
|
|
#
|
|
# Independent compose project. Joins three external networks:
|
|
#
|
|
# marketplace-net reaches the indexer at http://marketplace-indexer:8090
|
|
# apex-dashboard_apex-net reaches ws://ogmios:1337 in the apex-dashboard
|
|
# testnet stack (required when LIVE_CHAIN=1 so the
|
|
# /v1/accept endpoint can submit Accept txs)
|
|
# dashboard_default Traefik public entrypoint (mp-buyer.*)
|
|
#
|
|
# All three networks must exist before bringing this up.
|
|
#
|
|
# Bring up:
|
|
# docker compose -f deploy/testnet/docker-compose.buyer.yml up -d --build
|
|
|
|
name: marketplace-buyer
|
|
|
|
services:
|
|
buyer:
|
|
build:
|
|
context: ../..
|
|
dockerfile: buyer/Dockerfile
|
|
container_name: marketplace-buyer
|
|
restart: unless-stopped
|
|
env_file:
|
|
- path: ../../buyer/.env
|
|
required: false
|
|
environment:
|
|
# Compose-level override — buyer talks to indexer via service name.
|
|
INDEXER_URL: http://marketplace-indexer:8090
|
|
BUYER_PORT: "8070"
|
|
# LiveOgmiosProvider uses HTTP JSON-RPC against the apex-dashboard
|
|
# testnet stack's Ogmios. /v1/accept depends on this; without it the
|
|
# endpoint returns 503 service_unavailable.
|
|
OGMIOS_URL: http://ogmios:1337
|
|
LIVE_CHAIN: "1"
|
|
NETWORK_ID: "0"
|
|
# Path-B PiperTTS proxy target. Public CF hostname so the buyer-app
|
|
# reaches it via the internet (we don't share a docker network with
|
|
# apex-simulation). When this is unset, /v1/synth-speech responds 503
|
|
# and the SPA's PiperTTSForm shows a clear error.
|
|
TTS_PIPER_BASE_URL: "https://tts-piper.vector.testnet.apexfusion.org"
|
|
# Response archive — SQLite metadata + per-escrow artefact files for
|
|
# off-chain dispute resolution. Host-bind-mounted below so it survives
|
|
# container rebuilds and an operator can `ls`/back-up directly.
|
|
ARCHIVE_DIR: /data/archive
|
|
volumes:
|
|
# Bind-mount path is repo-root-relative (../../) so the on-disk
|
|
# archive lives at $REPO_ROOT/data/buyer/archive — same convention
|
|
# we use elsewhere. Without this, docker-compose's default would
|
|
# place the volume at deploy/testnet/data/... (relative to THIS
|
|
# file), which is harder to find and back up.
|
|
- ../../data/buyer/archive:/data/archive
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8070/healthz >/dev/null || exit 1"]
|
|
interval: 20s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 15s
|
|
networks:
|
|
- marketplace-net
|
|
- apex-net
|
|
- traefik-net
|
|
labels:
|
|
- "traefik.enable=true"
|
|
- "traefik.docker.network=dashboard_default"
|
|
- "traefik.http.routers.mp-buyers.entrypoints=web"
|
|
- "traefik.http.routers.mp-buyers.rule=Host(`mp-buyers.vector.testnet.apexfusion.org`)"
|
|
- "traefik.http.services.mp-buyers.loadbalancer.server.port=8070"
|
|
logging:
|
|
driver: json-file
|
|
options:
|
|
max-size: "10m"
|
|
max-file: "3"
|
|
|
|
networks:
|
|
marketplace-net:
|
|
external: true
|
|
name: marketplace-net
|
|
apex-net:
|
|
external: true
|
|
name: apex-dashboard_apex-net
|
|
traefik-net:
|
|
external: true
|
|
name: dashboard_default
|