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.
103 lines
4.9 KiB
Docker
103 lines
4.9 KiB
Docker
# syntax=docker/dockerfile:1.7
|
|
#
|
|
# buyer/Dockerfile — multi-stage image for the buyer Express + React app.
|
|
#
|
|
# Two artifacts produced:
|
|
# 1. Vite-built static UI bundle at buyer/dist/{index.html, assets/...}
|
|
# 2. tsx-runnable Express server at buyer/src/index.ts
|
|
#
|
|
# The Express server (buyer/src/index.ts:78) reads
|
|
# resolve(process.cwd(), "buyer/dist")
|
|
# at boot and serves that directory, so we set the runtime WORKDIR to /repo
|
|
# (the repo root, since the path is "buyer/dist" not "./dist") and ensure the
|
|
# Vite output lands there.
|
|
#
|
|
# We do NOT run `tsc` for the server because @marketplace/shared exports .ts
|
|
# files directly via package.json#exports — the tsx loader handles them. The
|
|
# buyer's tsconfig.json#outDir of "./dist" would in fact COLLIDE with the
|
|
# Vite outDir if both were run, since vite.config.ts has emptyOutDir:true and
|
|
# would wipe any tsc-emitted index.js. tsx-only runtime avoids this trap.
|
|
#
|
|
# Build:
|
|
# docker build -f buyer/Dockerfile -t marketplace-buyer .
|
|
|
|
ARG NODE_VERSION=20.19.0
|
|
|
|
# ─── builder ──────────────────────────────────────────────────────────────
|
|
FROM node:${NODE_VERSION}-alpine AS builder
|
|
WORKDIR /repo
|
|
|
|
# better-sqlite3 (used by the response archive) ships native bindings —
|
|
# python3 + make + g++ are needed during pnpm install for the prebuild
|
|
# install / fallback compile. Toolchain stays in the BUILDER stage only.
|
|
RUN apk add --no-cache python3 make g++
|
|
|
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
|
|
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
|
COPY packages/shared/package.json ./packages/shared/package.json
|
|
COPY buyer/package.json ./buyer/package.json
|
|
COPY supplier/package.json ./supplier/package.json
|
|
COPY indexer/package.json ./indexer/package.json
|
|
|
|
# Buyer needs vite + tailwind + react devDeps to build the UI bundle, so we
|
|
# must install with dev deps included (default).
|
|
RUN pnpm install --frozen-lockfile --filter '@marketplace/buyer...'
|
|
|
|
# better-sqlite3 native binding fetch (mirrors indexer/Dockerfile). pnpm 10's
|
|
# approve-builds gate skips the postinstall on native modules, so we manually
|
|
# resolve the binary into build/Release/.
|
|
RUN BSQ_DIR=$(ls -d node_modules/.pnpm/better-sqlite3@*/node_modules/better-sqlite3 2>/dev/null | head -1) \
|
|
&& [ -n "$BSQ_DIR" ] || (echo "FATAL: better-sqlite3 not in pnpm store" && exit 1) \
|
|
&& echo "rebuilding native binding at: $BSQ_DIR" \
|
|
&& (cd "$BSQ_DIR" && (npx --yes prebuild-install --runtime=node || npm install --build-from-source --no-save)) \
|
|
&& ls -la "$BSQ_DIR/build/Release/better_sqlite3.node"
|
|
|
|
# libsodium-wrappers-sumo (transitive via @lucid-evolution/lucid → CML) does
|
|
# `import "./libsodium-sumo.mjs"` from its wrapper, but pnpm's strict store
|
|
# layout puts the .mjs in a sibling pkg, breaking the relative import. Patch
|
|
# by copying the file into the wrapper's expected path. Same fix as the
|
|
# supplier Dockerfile — required now that the buyer's /v1/accept endpoint
|
|
# goes through buildAcceptTx → buildLiveTxForAccept → lucid.
|
|
RUN set -e \
|
|
&& SRC=$(find node_modules/.pnpm -name "libsodium-sumo.mjs" -path "*/libsodium-sumo/dist/*" 2>/dev/null | head -1) \
|
|
&& DST=$(find node_modules/.pnpm -type d -path "*libsodium-wrappers-sumo@*/node_modules/libsodium-wrappers-sumo/dist/modules-sumo-esm" 2>/dev/null | head -1) \
|
|
&& [ -n "$SRC" ] && [ -n "$DST" ] || (echo "FATAL: libsodium-sumo paths not resolved" && exit 1) \
|
|
&& cp "$SRC" "$DST/" \
|
|
&& echo "patched libsodium wrapper: $DST/libsodium-sumo.mjs"
|
|
|
|
COPY packages/shared ./packages/shared
|
|
COPY buyer ./buyer
|
|
# loadBlueprint at runtime reads contracts/marketplace/plutus.json to derive
|
|
# the escrow script address + attach the spending validator script in
|
|
# buildLiveTxForAccept. Same as the supplier image.
|
|
COPY contracts/marketplace/plutus.json ./contracts/marketplace/plutus.json
|
|
|
|
# Build the static UI into buyer/dist (Vite outDir is buyer/dist;
|
|
# emptyOutDir:true is fine here because we run this exactly once).
|
|
RUN pnpm --filter @marketplace/buyer build:ui
|
|
|
|
# ─── runtime ──────────────────────────────────────────────────────────────
|
|
FROM node:${NODE_VERSION}-alpine AS runtime
|
|
WORKDIR /repo
|
|
|
|
RUN apk add --no-cache tini
|
|
|
|
# Copy the entire workspace incl. node_modules. This includes the buyer/dist
|
|
# Vite bundle that the Express server will static-serve.
|
|
COPY --from=builder /repo /repo
|
|
|
|
RUN chown -R node:node /repo
|
|
USER node
|
|
|
|
ENV NODE_ENV=production
|
|
ENV BUYER_PORT=8070
|
|
EXPOSE 8070
|
|
|
|
# Buyer health endpoint is /healthz (see buyer/src/server.ts:26).
|
|
HEALTHCHECK --interval=20s --timeout=5s --start-period=15s --retries=3 \
|
|
CMD wget -qO- http://127.0.0.1:${BUYER_PORT}/healthz >/dev/null || exit 1
|
|
|
|
ENTRYPOINT ["/sbin/tini", "--"]
|
|
CMD ["node_modules/.bin/tsx", "buyer/src/index.ts"]
|