Bonded-escrow marketplace for prompt → response between buyers and
local-LLM suppliers, settling on Vector (UTxO) via Aiken Plutus V3
validators. Lifecycle PostEscrow → Claim → Submit → Accept proven on
Vector testnet 2026-05-06; AcceptEscrow tx 125e3cfe…7dc100d distributes
payment + bonds per the validator's distribution rule.
Workspace layout (pnpm + TypeScript):
contracts/marketplace/ Aiken validators (advert + escrow) + plutus.json
packages/shared/ Tx-builders (lucid-evolution), CBOR codecs,
chain providers (mock / read-only / live Ogmios)
supplier/ OpenAI-compatible HTTP node (Ollama by default;
vLLM / llama.cpp / OpenRouter / OpenClaw all
swap-in via OLLAMA_URL); 202+poll chat handler
buyer/ Express + React SPA; server-side runs the full
chain lifecycle via /v1/submit-prompt and
/v1/accept; SPA never holds the buyer key
indexer/ Chain-follower (extends apex-dashboard pattern),
SSE event stream + REST queries
indexer-ui/ Operator dashboard (events + suppliers + escrow
lookup)
tests/ 1098 vitest unit + integration tests (10 skipped
under M1-F-D-defer for UPLC test plumbing)
deploy/testnet/ Per-service docker-compose for the Hetzner stack
docs/EXECUTIVE_SUMMARY.* Marp deck (PDF + PPTX) — 11 slides, mermaid
diagrams pre-rendered for portable export
docs/CLOUDFLARE_WAF_BYPASS.md CF Skip-rule runbook for /v1/* paths
docs/ARCHITECTURE.md canonical spec
Live demo (testnet, 2026-05-06):
https://mp-buyers.vector.testnet.apexfusion.org (buyer SPA)
https://mp-suppliers.vector.testnet.apexfusion.org (supplier API)
https://mp-indexer.vector.testnet.apexfusion.org (indexer + UI)
87 lines
4.3 KiB
Docker
87 lines
4.3 KiB
Docker
# syntax=docker/dockerfile:1.7
|
|
#
|
|
# supplier/Dockerfile — multi-stage image for the supplier node.
|
|
#
|
|
# Strategy: install workspace deps in a builder stage, then ship the source
|
|
# tree + node_modules to a slim runtime stage that runs the entry point with
|
|
# tsx. We do NOT compile TypeScript ahead-of-time because @marketplace/shared
|
|
# exports .ts files via package.json#exports (`"./chain": "./src/chain/index.ts"`),
|
|
# which means a `tsc`-compiled `dist/index.js` cannot resolve those imports
|
|
# at runtime without an additional build step on the shared package. Running
|
|
# tsx is what apex-dashboard does and matches that working pattern.
|
|
#
|
|
# Build context MUST be the repo root so pnpm can resolve workspace deps:
|
|
# docker build -f supplier/Dockerfile -t marketplace-supplier .
|
|
#
|
|
# Base image: node:20-alpine. Supplier has no native compile dependencies
|
|
# (@noble/ed25519, express, cbor-x are pure JS). Stay on alpine for size.
|
|
|
|
ARG NODE_VERSION=20.19.0
|
|
|
|
# ─── builder ──────────────────────────────────────────────────────────────
|
|
FROM node:${NODE_VERSION}-alpine AS builder
|
|
WORKDIR /repo
|
|
|
|
# pnpm via corepack — lockfile pins pnpm@10.33.2.
|
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate
|
|
|
|
# Workspace manifests first to maximise layer cache reuse.
|
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json .npmrc ./
|
|
COPY packages/shared/package.json ./packages/shared/package.json
|
|
COPY supplier/package.json ./supplier/package.json
|
|
# Buyer + indexer manifests are needed because pnpm-lock.yaml indexes the
|
|
# whole workspace. They are referenced but not installed (--filter excludes).
|
|
COPY buyer/package.json ./buyer/package.json
|
|
COPY indexer/package.json ./indexer/package.json
|
|
|
|
# Install only what supplier needs (and its transitive workspace deps).
|
|
RUN pnpm install --frozen-lockfile --filter '@marketplace/supplier...'
|
|
|
|
# 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. ARCHITECTURE.md §9 #16.
|
|
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 the actual source — shared + supplier + Aiken blueprint (loadBlueprint
|
|
# reads contracts/marketplace/plutus.json at runtime to derive escrow addr +
|
|
# attach the spending validator script in M1-F-4 live-CBOR builders).
|
|
COPY packages/shared ./packages/shared
|
|
COPY supplier ./supplier
|
|
COPY contracts/marketplace/plutus.json ./contracts/marketplace/plutus.json
|
|
|
|
# ─── runtime ──────────────────────────────────────────────────────────────
|
|
FROM node:${NODE_VERSION}-alpine AS runtime
|
|
WORKDIR /repo
|
|
|
|
# tini for signal-forwarding (graceful SIGTERM → process.exit). wget for
|
|
# healthcheck (alpine has busybox wget by default).
|
|
RUN apk add --no-cache tini
|
|
|
|
# Copy installed node_modules + source from builder.
|
|
COPY --from=builder /repo /repo
|
|
|
|
# Drop privileges. The official node image ships a non-root `node` user.
|
|
RUN chown -R node:node /repo
|
|
USER node
|
|
|
|
ENV NODE_ENV=production
|
|
ENV PORT=8080
|
|
EXPOSE 8080
|
|
|
|
# Healthcheck — supplier exposes /healthz (standardized 2026-04-27 across all
|
|
# services — see ARCHITECTURE.md §5). /healthz is a stateless 200-{ok:true}
|
|
# probe that NEVER touches chain or supplier state. /status remains as a
|
|
# separate route reporting free/working/offline marketplace state.
|
|
HEALTHCHECK --interval=20s --timeout=5s --start-period=15s --retries=3 \
|
|
CMD wget -qO- http://127.0.0.1:${PORT}/healthz >/dev/null || exit 1
|
|
|
|
# tsx is hoisted to /repo/node_modules/.bin by pnpm — invoke via the workspace bin.
|
|
ENTRYPOINT ["/sbin/tini", "--"]
|
|
CMD ["node_modules/.bin/tsx", "supplier/src/index.ts"]
|