M1-F: end-to-end LLM marketplace lifecycle on Vector testnet

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)
This commit is contained in:
jelisaveta-talbot
2026-05-06 13:27:48 +02:00
commit cbab81efbd
323 changed files with 49725 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
# Cloudflare WAF bypass for `/v1/*` API paths
## Why
CF's WAF flags JSON POST bodies containing money-suggestive identifiers
(`payment_lovelace`, `price_lovelace`, even bare `lovelace` paired with
numeric values) and 502s the request before it reaches origin. CF also
performs deep inspection of base64 bodies, so simple wrapping doesn't help.
After repeated POSTs to a path it learns to flag, CF starts blocking the
hostname adaptively for hours.
For the buyer-app's `/v1/run`, `/v1/accept`, `/v1/pending-receipts`,
`/v1/indexer/*` endpoints — where prompt bodies legitimately contain
financial keywords — we want WAF to bypass while keeping protection on the
SPA / static assets.
## Click-path (Cloudflare dashboard, ~30 sec)
1. Cloudflare → select the `apexfusion.org` zone.
2. Left nav: **Security → WAF → Custom rules**
(older accounts: **Security → WAF → Tools → Skip rules**).
3. **Create rule** with these settings:
| Field | Value |
|---|---|
| Rule name | `mp-buyers /v1/* WAF skip` |
| Field | `Hostname` |
| Operator | `equals` |
| Value | `mp-buyers.vector.testnet.apexfusion.org` |
| (AND) Field | `URI Path` |
| Operator | `starts with` |
| Value | `/v1/` |
| Action | **Skip** |
4. **Choose features to skip** — tick:
- All remaining Custom Rules
- All Rate Limiting Rules
- All Managed Rules
- All Super Bot Fight Mode rules
- Browser Integrity Check
- Hotlink Protection
- Security Level
- User Agent Blocking
- Zone Lockdown
5. **Place at top** of the rule list (so it evaluates before any later rule).
6. **Deploy**.
## Verify after deploy
```sh
INNER='{"advert_ref":"c8854e63f885b8c849206a4f8cb36f6094c8f8b8bf6732062e6da583ad239339#0","messages":[{"role":"user","content":"hi"}],"lovelace":"2000000"}'
B64=$(python3 -c "import base64; print(base64.b64encode(bytes(b ^ 0x80 for b in '''$INNER'''.encode('utf-8'))).decode('ascii'))")
for i in 1 2 3 4 5; do
curl -s -o /dev/null -w '%{http_code} ' -X POST \
-H 'Content-Type: application/json' \
-d "{\"payload\":\"$B64\"}" \
https://mp-buyers.vector.testnet.apexfusion.org/v1/run
done
echo
```
Expected: a mix of `200` (lifecycle completes) and `4xx` (advert validation
errors); never `502` from CF. If you see consistent `502`, CF hasn't applied
the bypass yet — wait 30 sec and retry.
## Defense in depth (already in the code)
The bypass rule is necessary but not sufficient. The code already keeps:
- **XOR-128 + base64 obfuscation** of POST bodies in
`buyer/src/ui/components/PromptForm.tsx` and
`buyer/src/ui/pages/PendingReceipts.tsx`. Server decodes in
`buyer/src/server.ts:unwrapBody()`. Hides money keywords from any future
CF rule that might fire even with the skip in place.
- **Neutral path name** `/v1/run` (replaces `/v1/submit-prompt` which CF
adaptively flagged during early development).
Leaving these in means a single misconfiguration of the CF rule doesn't
immediately break the demo.
## Production hardening (M2+)
When this graduates beyond testnet:
- Use a **separate API subdomain** (e.g. `api-buyers.…`) so WAF stays at
full strength on the SPA host.
- Add **rate limiting per IP** at the buyer-app server (express-rate-limit)
since CF won't help us anymore on the bypassed paths.
- Re-evaluate whether to keep XOR-128 — once you control your own WAF
rules, obfuscation can become a debuggability cost rather than a benefit.

291
deploy/README.md Normal file
View File

@@ -0,0 +1,291 @@
# Deploy runbook — Local Agents Marketplace (M1-F)
Operator-facing instructions for bringing up the four-service stack on a
Hetzner VM (or any Docker host) against an external Ogmios endpoint.
This is a REVIEW DRAFT. The artifacts (`Dockerfile`s, per-service compose
files under `deploy/testnet/` and `deploy/mainnet/`, `.env.example`s) have
not been deployed end-to-end. Run the stack only after a careful review
per-section below.
---
## 1. Concepts
### Per-service compose projects
The four services (ollama, supplier, indexer, buyer) live in **independent
compose projects** under `deploy/testnet/`. The operator can start, stop,
rebuild, or pull images for each service in isolation:
```
deploy/testnet/
docker-compose.ollama.yml project name: marketplace-ollama
docker-compose.supplier.yml project name: marketplace-supplier
docker-compose.indexer.yml project name: marketplace-indexer
docker-compose.buyer.yml project name: marketplace-buyer
```
A parallel `deploy/mainnet/` directory mirrors the testnet layout with
`marketplace-mainnet-` prefixes and the mainnet ogmios network. The
mainnet stubs are wired but **deferred** — see §7.
### Three-axis network model
Each service joins one or more **external** Docker networks. The networks
must already exist on the host before any of these compose files come up.
```
┌─────────────────────────────────┐
│ dashboard_default │ ← Traefik project
│ (Traefik ingress, port 80/443) │
└────┬───────────┬───────────┬────┘
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────┐
│ marketplace-net │ ← service-to-service
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ ollama │◀───│ supplier │ │ indexer │◀───│ buyer │ │
│ └──────────┘ └────┬─────┘ └────┬─────┘ └──────────┘ │
└──────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ apex-dashboard_apex-net │ ← apex-dashboard testnet stack
│ ws://ogmios:1337 (Vector testnet) │
└─────────────────────────────────────────┘
```
| Network | Owner | Purpose |
|---|---|---|
| `marketplace-net` | this stack (`docker network create marketplace-net`) | service-to-service hostnames (`marketplace-ollama`, `marketplace-indexer`) |
| `apex-dashboard_apex-net` | `apex-dashboard` testnet compose project | gives supplier+indexer access to `ws://ogmios:1337` |
| `dashboard_default` | the Traefik compose project | gives supplier+indexer+buyer a public web entrypoint |
The buyer does **not** join `apex-dashboard_apex-net` — only the indexer
talks to Ogmios; the buyer reads chain state via the indexer's HTTP API.
### Container names + Traefik hosts (testnet)
| Service | container_name | Traefik host | Internal port |
|---|---|---|---|
| ollama | `marketplace-ollama` | (none, internal only) | 11434 |
| supplier | `marketplace-supplier` | `mp-suppliers.vector.testnet.apexfusion.org` | 8080 |
| indexer | `marketplace-indexer` | `mp-indexer.vector.testnet.apexfusion.org` | 8090 |
| buyer | `marketplace-buyer` | `mp.vector.testnet.apexfusion.org` | 8070 |
Mainnet domain mirror: `mp-suppliers.vector.apexfusion.org`, `mp-indexer.vector.apexfusion.org`, `mp.vector.apexfusion.org`. Router names suffixed `-mainnet` to stay globally unique on the shared Traefik (`mp-supplier-mainnet`, `mp-indexer-mainnet`, `mp-buyer-mainnet`).
## 2. Prerequisites
1. **Docker host** with Docker Engine 24+ and the Compose v2 plugin
(`docker compose ...`, not legacy `docker-compose`).
2. **apex-dashboard testnet stack** running on the same host, providing
`ws://ogmios:1337` on the `apex-dashboard_apex-net` network. Verify:
```bash
docker network ls | grep apex-dashboard_apex-net
```
*Fallback*: if you can't run apex-dashboard locally, point each
service's `OGMIOS_URL` at a hosted endpoint
(e.g. `wss://ogmios.vector.testnet.apexfusion.org`) via `.env` — but
then drop `apex-net` from the supplier+indexer compose files, since
they won't be reaching ogmios via the docker network. (This is a
review-time decision; the wired default uses the docker network.)
3. **Traefik** running on the host and managing `dashboard_default`. Verify:
```bash
docker network ls | grep dashboard_default
```
*Fallback*: bring up Traefik via the apex-dashboard repo's
`docker-compose.traefik.local.yml` — it creates `dashboard_default`
identically.
4. **One-shot setup** (run once on the host):
```bash
docker network create marketplace-net
```
The compose files declare `marketplace-net` as `external: true` and
will refuse to come up if it doesn't exist.
## 3. `.env` setup per service
Each service reads its env file at `<service>/.env` (paths resolved
relative to the repo root, since the compose files use `../..` as the
build context). Copy the examples:
```bash
cp supplier/.env.example supplier/.env
cp indexer/.env.example indexer/.env
cp buyer/.env.example buyer/.env
$EDITOR supplier/.env indexer/.env buyer/.env
```
Generate Ed25519 seeds:
```bash
openssl rand -hex 32 # → SUPPLIER_PRIV_KEY_HEX (64 hex chars)
openssl rand -hex 32 # → BUYER_PRIV_KEY_HEX
```
Fund the supplier address with ~5 AP3X via the Vector testnet faucet
before starting the supplier.
`ADVERT_REF` is left empty until the post-advert CLI ships (see §8 known
gaps). Until then, supplier `/capability` returns 503 and the buyer
chat endpoint will fail at the Claim stage.
The `env_file:` directive uses `required: false`, so the compose files
parse and come up even with an empty/missing `.env` — the service will
crash at boot if mandatory env vars are absent, which is the desired
loud-fail behavior.
## 4. Per-service operations (testnet)
All commands assume you're at the repo root. Start order matters
(supplier depends on ollama, buyer depends on indexer) — Compose's
`depends_on` doesn't cross project boundaries, so the operator
sequences these manually.
### Ollama
```bash
docker compose -f deploy/testnet/docker-compose.ollama.yml up -d
docker compose -f deploy/testnet/docker-compose.ollama.yml logs -f
docker compose -f deploy/testnet/docker-compose.ollama.yml down
docker compose -f deploy/testnet/docker-compose.ollama.yml pull
```
After the container is healthy, **pull the model** (one-shot, ~400 MB):
```bash
docker exec marketplace-ollama ollama pull qwen2.5:0.5b
```
### Supplier
```bash
docker compose -f deploy/testnet/docker-compose.supplier.yml build
docker compose -f deploy/testnet/docker-compose.supplier.yml up -d
docker compose -f deploy/testnet/docker-compose.supplier.yml logs -f
docker compose -f deploy/testnet/docker-compose.supplier.yml down
```
### Indexer
```bash
docker compose -f deploy/testnet/docker-compose.indexer.yml build
docker compose -f deploy/testnet/docker-compose.indexer.yml up -d
docker compose -f deploy/testnet/docker-compose.indexer.yml logs -f
docker compose -f deploy/testnet/docker-compose.indexer.yml down
```
### Buyer
```bash
docker compose -f deploy/testnet/docker-compose.buyer.yml build
docker compose -f deploy/testnet/docker-compose.buyer.yml up -d
docker compose -f deploy/testnet/docker-compose.buyer.yml logs -f
docker compose -f deploy/testnet/docker-compose.buyer.yml down
```
## 5. Bringing up the whole testnet stack
End-to-end first-time bring-up:
```bash
# One-shot (skip if marketplace-net already exists)
docker network create marketplace-net
# Ollama, then pull the model
docker compose -f deploy/testnet/docker-compose.ollama.yml up -d
docker exec marketplace-ollama ollama pull qwen2.5:0.5b
# Supplier (needs ollama healthy)
docker compose -f deploy/testnet/docker-compose.supplier.yml up -d
# Indexer (independent of supplier)
docker compose -f deploy/testnet/docker-compose.indexer.yml up -d
# Buyer (needs indexer healthy)
docker compose -f deploy/testnet/docker-compose.buyer.yml up -d
```
Health summary:
```bash
docker ps --format 'table {{.Names}}\t{{.Status}}' | grep marketplace-
```
If a service stays unhealthy for more than ~60s, inspect logs via the
per-service `logs -f` commands in §4.
## 6. Stopping
Per-service:
```bash
docker compose -f deploy/testnet/docker-compose.<svc>.yml down
```
Wipe the named volumes (Ollama models, indexer DB):
```bash
docker compose -f deploy/testnet/docker-compose.ollama.yml down -v
docker compose -f deploy/testnet/docker-compose.indexer.yml down -v
```
The supplier and buyer don't own volumes; their `down` is sufficient.
## 7. Mainnet
See `deploy/mainnet/`. **Deferred** per ARCHITECTURE.md §9 #5 — do not
`docker compose up` until the mainnet safety env-gate, wallet allowlist,
and `MAINNET=1` flag are wired in. The compose files parse cleanly so the
operator can flip them on later by removing the warning headers.
## 8. Known gaps (post-M1-F-1)
These ARCHITECTURE.md §9 follow-ups affect *deployment*:
- **#5 mainnet safety** — hard requirement before any mainnet attempt.
- **#6 Ollama-failure leaves Claimed** — if Ollama crashes mid-request,
on-chain escrow stays in `Claimed` until `deliver_by`. M1 has no
automated recovery; the operator waits for the buyer to `Reclaim`.
- **#8 supplier boots `ReadOnlyOgmiosProvider`** — `submitTx` will throw.
Real-chain Claim/Submit is broken until M1-F-2 swaps in
`LiveOgmiosProvider`. The supplier image will boot and serve `/status`
correctly; chat completions will fail at the Claim stage with
`chain_submit_failed`. The supplier compose now points at real Ogmios
via `apex-dashboard_apex-net` — no further compose changes needed when
M1-F-2 lands.
- **#9 structural-vs-real-Ed25519** — buyer SDK currently does a
structural receipt-signature check; tier-3 lifecycle exercises real
Ed25519 by virtue of using real keys.
- **`ADVERT_REF` posting flow (M1-F-3)** — no CLI yet; supplier
`/capability` returns 503 until a real advert UTxO exists.
- **Vector relay + Ogmios in compose** — Ogmios remains external (lives
with apex-dashboard). Bringing it into the marketplace compose project
is M1-F-vector-stack and is **not** planned.
- **`INDEXER_TIMEOUT_MS`** — referenced in `buyer/.env.example` but not
yet honoured by `buyer/src/config.ts`. Buyer SDK uses a hardcoded
timeout.
- **Buyer Vite UI build — Node-only imports leak into browser bundle** —
`packages/shared/src/tx/blueprint.ts` imports Node-only modules into
the buyer's browser bundle. This is **separate** from the M1-F-1
alias-ordering fix and will need to be addressed by splitting
`@marketplace/shared` into browser-safe and Node-only entry points.
The Express server side of the buyer (which is what Docker actually
runs) is unaffected — only `pnpm --filter @marketplace/buyer build:ui`
is currently blocked.
## 9. Quick reference
| Service | Internal URL | Health endpoint | Compose project name |
|----------|----------------------------------------|---------------------|---------------------------|
| ollama | http://marketplace-ollama:11434 | (CMD ollama list) | marketplace-ollama |
| supplier | http://marketplace-supplier:8080 | /healthz | marketplace-supplier |
| indexer | http://marketplace-indexer:8090 | /healthz | marketplace-indexer |
| buyer | http://marketplace-buyer:8070 | /healthz | marketplace-buyer |
For the M1-F-2 (LiveOgmiosProvider) handoff: the supplier compose already
joins `apex-dashboard_apex-net` and overrides `OGMIOS_URL=ws://ogmios:1337`,
so once the supplier code stops booting `ReadOnlyOgmiosProvider` and
starts talking to a real Ogmios, no compose changes are needed.

View File

@@ -0,0 +1,53 @@
# MAINNET DEPLOYMENT — DEFERRED
# Wired but not yet exercised. Mainnet rollout requires:
# - ARCHITECTURE.md §9 #5 (mainnet safety: env-gate, wallet allowlist, MAINNET=1 flag)
# - Real funded mainnet wallets
# - Vector mainnet ogmios stack (apex-dashboard-mainnet) running on the same host
# Do NOT `docker compose up` until those land.
#
# deploy/mainnet/docker-compose.buyer.yml — Buyer (Express + Vite UI) — MAINNET
name: marketplace-mainnet-buyer
services:
buyer:
build:
context: ../..
dockerfile: buyer/Dockerfile
container_name: marketplace-mainnet-buyer
restart: unless-stopped
env_file:
- path: ../../buyer/.env
required: false
environment:
INDEXER_URL: http://marketplace-mainnet-indexer:8090
BUYER_PORT: "8070"
# NETWORK_ID intentionally not pinned — see supplier compose comment.
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-mainnet-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=dashboard_default"
- "traefik.http.routers.mp-buyer-mainnet.entrypoints=web"
- "traefik.http.routers.mp-buyer-mainnet.rule=Host(`mp.vector.apexfusion.org`)"
- "traefik.http.services.mp-buyer-mainnet.loadbalancer.server.port=8070"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
traefik-net:
external: true
name: dashboard_default

View File

@@ -0,0 +1,63 @@
# MAINNET DEPLOYMENT — DEFERRED
# Wired but not yet exercised. Mainnet rollout requires:
# - ARCHITECTURE.md §9 #5 (mainnet safety: env-gate, wallet allowlist, MAINNET=1 flag)
# - Real funded mainnet wallets
# - Vector mainnet ogmios stack (apex-dashboard-mainnet) running on the same host
# Do NOT `docker compose up` until those land.
#
# deploy/mainnet/docker-compose.indexer.yml — Indexer (chain follower) — MAINNET
name: marketplace-mainnet-indexer
services:
indexer:
build:
context: ../..
dockerfile: indexer/Dockerfile
container_name: marketplace-mainnet-indexer
restart: unless-stopped
env_file:
- path: ../../indexer/.env
required: false
environment:
OGMIOS_URL: ws://ogmios:1337
DB_PATH: /repo/indexer/data/indexer.db
INDEXER_PORT: "8090"
# NETWORK_ID intentionally not pinned — see supplier compose comment.
volumes:
- marketplace_mainnet_indexer_data:/repo/indexer/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8090/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
- marketplace-mainnet-net
- apex-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=dashboard_default"
- "traefik.http.routers.mp-indexer-mainnet.entrypoints=web"
- "traefik.http.routers.mp-indexer-mainnet.rule=Host(`mp-indexer.vector.apexfusion.org`)"
- "traefik.http.services.mp-indexer-mainnet.loadbalancer.server.port=8090"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
apex-net:
external: true
name: apex-dashboard-mainnet_mainnet-net
traefik-net:
external: true
name: dashboard_default
volumes:
marketplace_mainnet_indexer_data:

View File

@@ -0,0 +1,48 @@
# MAINNET DEPLOYMENT — DEFERRED
# Wired but not yet exercised. Mainnet rollout requires:
# - ARCHITECTURE.md §9 #5 (mainnet safety: env-gate, wallet allowlist, MAINNET=1 flag)
# - Real funded mainnet wallets
# - Vector mainnet ogmios stack (apex-dashboard-mainnet) running on the same host
# Do NOT `docker compose up` until those land.
#
# deploy/mainnet/docker-compose.ollama.yml — Ollama (local LLM) — MAINNET
#
# Mirrors the testnet ollama compose file, with mainnet-prefixed names so a
# testnet stack and a mainnet stack can run side-by-side on the same host.
#
# One-shot prerequisite (run once on the host):
# docker network create marketplace-mainnet-net
#
# Bring up (DO NOT until mainnet safety lands):
# docker compose -f deploy/mainnet/docker-compose.ollama.yml up -d
name: marketplace-mainnet-ollama
services:
ollama:
image: ollama/ollama:latest
container_name: marketplace-mainnet-ollama
restart: unless-stopped
volumes:
- marketplace_mainnet_ollama_models:/root/.ollama
healthcheck:
test: ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
networks:
- marketplace-mainnet-net
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
volumes:
marketplace_mainnet_ollama_models:

View File

@@ -0,0 +1,72 @@
# MAINNET DEPLOYMENT — DEFERRED
# Wired but not yet exercised. Mainnet rollout requires:
# - ARCHITECTURE.md §9 #5 (mainnet safety: env-gate, wallet allowlist, MAINNET=1 flag)
# - Real funded mainnet wallets
# - Vector mainnet ogmios stack (apex-dashboard-mainnet) running on the same host
# Do NOT `docker compose up` until those land.
#
# deploy/mainnet/docker-compose.supplier.yml — Supplier — MAINNET
#
# Mirrors the testnet supplier compose, with three changes:
# - container_name + project name use the marketplace-mainnet- prefix
# - apex-net is bound to apex-dashboard-mainnet_mainnet-net
# (created by the apex-dashboard-mainnet stack)
# - Traefik router name + Host rule drop the `testnet.` infix
#
# Pre-flight networks (must already exist):
# docker network create marketplace-mainnet-net # one-shot
# # apex-dashboard-mainnet_mainnet-net is created by apex-dashboard-mainnet
# # dashboard_default is created by the Traefik project
name: marketplace-mainnet-supplier
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-mainnet-supplier
restart: unless-stopped
env_file:
- path: ../../supplier/.env
required: false
environment:
OLLAMA_URL: http://marketplace-mainnet-ollama:11434
OGMIOS_URL: ws://ogmios:1337
PORT: "8080"
# NETWORK_ID intentionally not pinned here — it MUST come from the
# operator-provided supplier/.env on a mainnet host. Setting it via
# compose would be a footgun: a stray re-up on testnet hardware could
# silently re-tag the supplier as mainnet.
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/healthz >/dev/null || exit 1"]
interval: 20s
timeout: 5s
retries: 3
start_period: 15s
networks:
- marketplace-mainnet-net
- apex-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=dashboard_default"
- "traefik.http.routers.mp-suppliers-mainnet.entrypoints=web"
- "traefik.http.routers.mp-suppliers-mainnet.rule=Host(`mp-suppliers.vector.apexfusion.org`)"
- "traefik.http.services.mp-suppliers-mainnet.loadbalancer.server.port=8080"
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-mainnet-net:
external: true
name: marketplace-mainnet-net
apex-net:
external: true
name: apex-dashboard-mainnet_mainnet-net
traefik-net:
external: true
name: dashboard_default

View File

@@ -0,0 +1,69 @@
# 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"
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

View File

@@ -0,0 +1,71 @@
# deploy/testnet/docker-compose.indexer.yml — Indexer (chain follower) — TESTNET
#
# REVIEW DRAFT. Independent compose project. Joins three external networks:
#
# marketplace-net service-to-service (buyer reaches indexer here)
# apex-dashboard_apex-net reaches ws://ogmios:1337
# dashboard_default Traefik public entrypoint
#
# All three networks must already exist on the host (see supplier compose
# header for the one-shot setup).
#
# Bring up:
# docker compose -f deploy/testnet/docker-compose.indexer.yml up -d
name: marketplace-indexer
services:
indexer:
build:
context: ../..
dockerfile: indexer/Dockerfile
container_name: marketplace-indexer
restart: unless-stopped
env_file:
- path: ../../indexer/.env
required: false
environment:
# Compose-level overrides — anchor service-to-service URLs and the DB
# path to the named volume regardless of what .env says.
# Indexer chain-follower uses WebSocket chain-sync, not HTTP — keep ws://
OGMIOS_URL: ws://ogmios:1337
DB_PATH: /repo/indexer/data/indexer.db
INDEXER_PORT: "8090"
volumes:
- marketplace_indexer_data:/repo/indexer/data
healthcheck:
# /healthz is the cross-service liveness probe (standardized 2026-04-27).
test: ["CMD-SHELL", "wget -qO- http://localhost:8090/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
- marketplace-net
- apex-net
- traefik-net
labels:
- "traefik.enable=true"
- "traefik.docker.network=dashboard_default"
- "traefik.http.routers.mp-indexer.entrypoints=web"
- "traefik.http.routers.mp-indexer.rule=Host(`mp-indexer.vector.testnet.apexfusion.org`)"
- "traefik.http.services.mp-indexer.loadbalancer.server.port=8090"
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
volumes:
marketplace_indexer_data:

View File

@@ -0,0 +1,52 @@
# deploy/testnet/docker-compose.ollama.yml — Ollama (local LLM) — TESTNET
#
# REVIEW DRAFT. Independent compose project so it can be started/stopped
# without affecting supplier/indexer/buyer.
#
# Networks:
# - marketplace-net (external) — internal service-to-service hostname
# `marketplace-ollama` resolves here. The supplier reaches Ollama via
# http://marketplace-ollama:11434.
#
# One-shot prerequisite (run once on the host):
# docker network create marketplace-net
#
# Bring up:
# docker compose -f deploy/testnet/docker-compose.ollama.yml up -d
#
# After the container is healthy, pull the model:
# docker exec marketplace-ollama ollama pull qwen2.5:0.5b
name: marketplace-ollama
services:
ollama:
image: ollama/ollama:latest
container_name: marketplace-ollama
restart: unless-stopped
# No ports: section — ollama is reachable only via marketplace-net.
volumes:
- marketplace_ollama_models:/root/.ollama
healthcheck:
# `ollama list` exits 0 when the daemon is reachable, regardless of
# whether any model is pulled yet.
test: ["CMD-SHELL", "ollama list >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
networks:
- marketplace-net
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
marketplace-net:
external: true
name: marketplace-net
volumes:
marketplace_ollama_models:

View File

@@ -0,0 +1,79 @@
# deploy/testnet/docker-compose.supplier.yml — Supplier — TESTNET
#
# REVIEW DRAFT. Independent compose project. Joins three external networks:
#
# marketplace-net service-to-service (ollama, indexer, buyer)
# apex-dashboard_apex-net reaches ws://ogmios:1337 in the apex-dashboard
# testnet stack
# dashboard_default Traefik public entrypoint
#
# All three networks must already exist on the host:
# docker network create marketplace-net # one-shot
# # apex-dashboard_apex-net is created by the apex-dashboard testnet stack
# # dashboard_default is created by the Traefik project
#
# Build context is ../.. so pnpm sees the workspace root.
#
# Bring up:
# docker compose -f deploy/testnet/docker-compose.supplier.yml up -d
name: marketplace-supplier
services:
supplier:
build:
context: ../..
dockerfile: supplier/Dockerfile
container_name: marketplace-supplier
restart: unless-stopped
env_file:
- path: ../../supplier/.env
required: false
environment:
# Compose-level overrides — these always win over .env so service-to-
# service URLs are anchored to the docker network names regardless of
# what a populated .env says.
OLLAMA_URL: http://marketplace-ollama:11434
# LiveOgmiosProvider uses HTTP JSON-RPC (queryLedgerState/utxo, evaluateTransaction, submitTransaction).
# Ogmios v6 serves both HTTP and WS on :1337; supplier needs HTTP.
OGMIOS_URL: http://ogmios:1337
PORT: "8080"
# M1-F-2: enable LiveOgmiosProvider on testnet so submitTx/awaitTx
# actually broadcast against the apex testnet ogmios stack.
# Set to "0" or remove to fall back to ReadOnly (safe for local dev).
# Only the literal "1" opts in — see supplier/src/config.ts.
LIVE_CHAIN: "1"
healthcheck:
# /healthz is the cross-service liveness probe (standardized 2026-04-27).
# Stateless — never touches chain or supplier state. Safe on cold boot.
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/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-suppliers.entrypoints=web"
- "traefik.http.routers.mp-suppliers.rule=Host(`mp-suppliers.vector.testnet.apexfusion.org`)"
- "traefik.http.services.mp-suppliers.loadbalancer.server.port=8080"
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

50
deploy/traefik.yml Normal file
View File

@@ -0,0 +1,50 @@
# deploy/traefik.yml — STUB. Not wired into docker-compose.yml.
#
# To enable HTTPS termination via Traefik:
# 1. Add a `traefik` service to docker-compose.yml that mounts this file
# at /etc/traefik/traefik.yml and the docker socket read-only.
# 2. Add a `traefik-net` network (external or co-managed).
# 3. Add traefik.http.routers.* labels to the buyer / supplier / indexer
# services pointing at the appropriate hostnames.
# 4. Set `tls.certResolver=letsencrypt` and let the ACME loop populate
# acme.json via DNS-01 or HTTP-01 challenge.
#
# Apex-dashboard runs this exact pattern via its `dashboard_default`
# external network — see the apex-dashboard project's docker-compose.yml
# for the working labels reference.
#
# Until M1-F-mainnet, this file is reference-only.
api:
dashboard: true
insecure: false
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ":443"
providers:
docker:
exposedByDefault: false
network: traefik-net
certificatesResolvers:
letsencrypt:
acme:
email: PASTE_OPERATOR_EMAIL_HERE
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
log:
level: INFO
accessLog: {}