- TypeScript 95.5%
- JavaScript 3.4%
- PLpgSQL 0.9%
- Dockerfile 0.1%
| apps | ||
| docker | ||
| docs | ||
| gateway | ||
| packages/shared | ||
| Requirements | ||
| scripts | ||
| .dockerignore | ||
| .env.example | ||
| .env.prod.example | ||
| .gitignore | ||
| AGENTS.md | ||
| docker-compose.prod.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| Reality_Check.md | ||
| tsconfig.base.json | ||
Get-Selling
Decentralized, federated e-commerce: Shopify-like multi-tenant shops with Fediverse-style trust, Ed25519-signed listings, earned federation, and an agent-ready API.
Status: Shop Migration complete (G1–G5). Node settings, shop dashboard, Product Studio with gallery/attributes/theming, public SSR storefront (
/s/:slug), per-store team + agent-key settings, guest checkout on mock gateway (Epic 2), earned federation with verified inbox, signed directory, sandbox + wash-trade, rules engine, and runner console (Epic 3), concentric federated search with ring badges (Epic 4), photo pipeline with conversion + thumbnails + lightbox, ship-ready fulfillment (methods + print-before-fulfill gate), node-local multi-seller cart, and node-to-node shop migration (encrypted.gsbackupexport → atomic import → trust review → old-shop fate), plus per-shop + node SMTP for outbound email (Messaging M0 backend + UI), and buyer–seller messaging end to end (threads + item Q&A + post-sale threads + merchant inbox + signed webhook + federated inquiries + Matrix), plus secure digital downloads (node policy + seller limits + paid-order delivery).
Documentation
Role-based guides with real screenshots from a running node:
- Documentation index
- Node Runner guide — identity, upload policy, federation, migration review.
- Shop Owner guide — catalog, theme, orders, messaging, team, payments.
- Shopper guide — storefront, search, Q&A, cart, guest checkout.
- Reference — roles, configuration, API, and federation.
Quickstart (one command, roughly)
Prerequisites: Node 20+, pnpm 9+, Docker + Compose.
cp .env.example apps/api/.env # then set SESSION_SECRET + PKEY_ENCRYPTION_KEY
pnpm install
pnpm up # postgres :5440, redis :6379, meilisearch :7700
pnpm dev # API on :4000 (migrations auto-run on boot)
pnpm dev:web # dashboard on :22001 (proxies /api/v1 → API)
Register the first account — it becomes the node runner (superadmin):
curl -s -X POST localhost:4000/api/v1/auth/register \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","password":"a-strong-password"}'
(Account passwords need 12+ characters; the signup form enforces the same.)
Create your first shop (authenticated) and list it:
curl -s -X POST localhost:4000/api/v1/stores/ \
-H 'content-type: application/json' -H "cookie: $COOKIE" \
-d '{"name":"Demo Shop","slug":"demo-shop"}'
Health: GET /healthz. OpenAPI JSON: GET /docs/openapi.json.
Production Docker image (bmcgonag/get-selling:0.1.4)
A single self-contained image running the API (:4000) + dashboard/storefront
(:22001). DB migrations auto-run on API boot — no separate migrate step.
docker build -t bmcgonag/get-selling:0.1.4 .
Environment — copy the template and set the three secrets (generate each
with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))",
16 bytes for the pepper):
cp .env.prod.example .env.prod # then fill in the REQUIRED secrets
| Var | Required | What it does |
|---|---|---|
NODE_DOMAIN |
yes | Public origin for actor URIs + verify links (e.g. shop.example.com). Defaults to localhost:3000 for local testing. |
SESSION_SECRET |
yes (≥32 chars) | Signs session cookies. API refuses to boot without it. |
PKEY_ENCRYPTION_KEY |
yes (64 hex chars) | Master key for every AES-256-GCM sealed secret (store keys, SMTP passwords, payment/webhook/Matrix/vendor credentials). Losing it means re-entering them all. |
WASH_TRADE_PEPPER |
yes (≥16 chars) | HMAC pepper for buyer-subnet hashes. Raw IPs are never stored. |
POSTGRES_PASSWORD / MEILI_MASTER_KEY |
no | Must match the sibling services; defaults work out of the box. |
DEFAULT_CURRENCY |
no | Shop default (USD|EUR|GBP|CAD). |
COOKIE_SECURE |
no | Session-cookie Secure flag. Defaults on in production; the compose file sets "false" for plain-HTTP testing. Real deployments MUST terminate TLS (gateway/Caddy :443) and set "true". |
APP_API_PORT / APP_WEB_PORT |
no | Host ports (default :4000/:22001) if those are taken on the host. |
Run it (separate volumes from dev — test data never touches your dev DB):
docker compose -f docker-compose.prod.yml up -d
# overrides when ports clash: APP_API_PORT=4010 APP_WEB_PORT=22101 docker compose -f docker-compose.prod.yml up -d
Browse http://localhost:22001 (dashboard/storefront), API on :4000.
Register the first account — it becomes the node runner.
Notes:
- Migrations (
001–031) run automatically at boot; the stack is healthy whenGET :4000/healthzreturns 200 (compose healthcheck covers this). - The web rewrites are baked at build time to the same-container API
(
http://localhost:4000); rebuild to point them elsewhere. - Uploads live in a named
uploadsvolume (/data/uploadsin-container). - First smoke (all verified against the running image): register → login
(session cookie) → create + activate shop → create listing → guest order →
mock-pay → upload photo (gets
thumbUrl) → storefront + product pages 200.
Scripts
| Command | What it does |
|---|---|
pnpm up / down |
Start / stop compose services (postgres, redis, meili) |
pnpm dev |
API in watch mode (apps/api, port 4000) |
pnpm dev:web |
Dashboard in dev mode (apps/web, port 22001) |
pnpm test |
Unit tests (shared + api, no infra needed) |
pnpm test:unit |
Unit tests across shared + api + web |
pnpm test:api |
API tests (needs Docker: throwaway PG + Redis, 241 specs) |
pnpm test:e2e |
Playwright dashboard E2E (boots API + web, wipes dev DB, 40 specs) |
pnpm test:docs |
Validate docs links, anchors, and screenshot assets |
pnpm build |
Build all workspaces |
pnpm typecheck |
Strict tsc across workspaces |
pnpm --filter @get-selling/api migrate |
Run pending SQL migrations manually (auto-run on boot) |
Configuration (apps/api/.env)
| Var | Default (compose) | Notes |
|---|---|---|
DATABASE_URL |
postgres://getselling:getselling@localhost:5440/getselling |
Host port 5440 (5432–5434 commonly taken) |
REDIS_URL |
redis://localhost:6379 |
Sessions, agent rate limits |
MEILI_URL / MEILI_MASTER_KEY |
http://localhost:7700 |
Listing index; PG FTS used if unreachable |
NODE_DOMAIN |
localhost:3000 |
Basis for store actor URIs |
DEFAULT_CURRENCY |
USD |
USD|EUR|GBP|CAD |
PORT |
4000 |
API listen port |
SESSION_SECRET |
(required, ≥32 chars) | Signs session cookies |
PKEY_ENCRYPTION_KEY |
(required, 64 hex chars) | AES-256-GCM for store + directory private keys |
UPLOAD_DIR |
./uploads |
Local disk volume for product media |
DIRECTORY_URLS |
https://directory.get-selling.example (stub — discovery disabled for .example hosts) |
Comma-separated registries; first is primary, rest mirrors; empty = seed/manual peers only |
WASH_TRADE_PEPPER |
dev-only-wash-pepper (override in prod, ≥16 chars) |
HMAC pepper for buyer-subnet hashes; raw IPs are never stored |
API overview (all under /api/v1)
| Area | Endpoints |
|---|---|
| Auth | POST /auth/register (first user → node_runner) · login · logout · GET /me |
| Node (runner-only writes) | GET/PUT /node/identity · GET/POST /node/policies |
| Stores | POST /stores/ · GET /stores/ (mine only) · GET/PATCH /stores/:id · GET /stores/:id/stats |
| Members (owner/admin) | GET/POST /stores/:id/members/ · PATCH/DELETE …/:userId |
| Listings | GET /stores/:id/listings/?q= (public) · GET …/:listingId (public) · POST · PATCH …/:listingId · DELETE |
| Federated search (public) | GET /stores/:id/search?q=&includeTransitive=&min/maxPrice=&fulfillmentType=&limit=&offset= — rings + summary |
| Storefront (public) | GET /storefront/:slug — active local stores only |
| Uploads | POST /stores/:id/uploads (multipart, policy-enforced) · served at /media/… |
| Digital downloads | GET/PUT /node/digital-delivery (runner policy + scheduled disable + owner notice) · POST/GET/DELETE /stores/:id/listings/:listingId/digital-asset (private seller upload) · POST /orders/:orderId/download-token + GET /orders/:orderId/download?token= (paid-order, limit/window-enforced delivery) |
| Agent keys | POST/GET /stores/:id/keys/ · PATCH …/:keyId · POST …/:keyId/revoke |
| Shop migration | GET/POST /stores/:id/export/preflight + /export (owner, passphrase-sealed .gsbackup) · POST /migration/import/preflight + /migration/import (atomic, idempotent) · `GET/POST /migration/reviews[/:id/carry |
| Shop email | GET/PUT /stores/:id/email (staff reads flags-only, owner/admin writes; password never leaks) · POST …/email/test (verify + send test email) |
| Node email (runner) | GET/PUT /node/email (outbound fallback; empty host clears it) · POST /node/email/test |
| Messaging | POST /stores/:id/questions (public ask, optional orderId) · GET /messages/verify?token= · GET/POST /threads/:id[/messages] (seller session or email token) · PATCH …/visibility (seller team) · GET/POST /stores/:id/email-blocks · GET …/listings/:id/questions (public Q&A) |
| Notifiers | Webhook: GET/PUT/DELETE /stores/:id/webhook + POST …/test + GET …/attempts (HMAC-signed fanout) · Matrix: GET/PUT/DELETE /stores/:id/matrix + POST …/test + GET …/attempts (room message fanout) · Vendors: GET/PUT/DELETE /stores/:id/vendors/{telegram,discord,slack,rocketchat,zulip} + POST …/test + GET …/attempts |
Listings are Ed25519-signed on create/update (version++, signedBy …#main-key);
physical shipments with rates over 3× item price are rejected as anomalies.
Federation (Epic 3 ✅ complete — receive + send + directory + sandbox + rules + console)
Every listing write broadcasts a signed ActivityPub activity
(Create/Update/Delete, application/activity+json) to all active
node_peers inboxes — best-effort, never blocking the response. Non-active
stores (sandbox/probation/banned) are skipped (outbox.skipped audit), so
sandbox blocks outgoing sync until graduation (F3c). Gallery alt text and
relative /media/… refs never hit the wire: images travel absolutized and
url-only, exactly matching what the inbox re-translates before verifying.
Discovery, not allowlists: any runner can host a signed node directory
(same API, no special mode). Nodes prove domain control by serving a nonce at
GET /.well-known/get-selling; the registry fetches it back before listing
them. Nodes register on boot and pull the Ed25519-signed list on boot + hourly
(GET /directory/nodes, verified against GET /directory/key, rotation-safe);
outages keep cached peers. New discoveries follow the runner policy
(node_config.federation_policy: follow_all → active, curated → pending);
pulls never auto-degrade existing rows. The directory is discovery only —
trust tiers, graduation, wash-trade, and rules still gate everything.
| Endpoint | Notes |
|---|---|
GET /api/v1/stores/:id/actor |
Public key document for a store |
POST /inbox |
Verified receive path (structure → key → trust → translate-then-verify → mirror/purge) |
GET /.well-known/get-selling |
This node's {domain, nonce} proof endpoint |
POST /directory/register |
{domain, nonce, stores[]} — fetch-back verified, ≤500 stores |
GET /directory/nodes · GET /directory/key |
Signed list + list-signing key |
POST /api/v1/directory/rotate-key |
Runner-only list-signing key rotation |
GET/POST /api/v1/federation/peers · PATCH …/:id |
Runner-only peer curation (list, manual seed add, status incl. unblock) |
GET/PUT /api/v1/federation/policy |
Runner-only discovery policy + graduation threshold (+ norms + registries read) |
GET /api/v1/federation/sandbox |
Runner-only sandbox queue with graduation progress |
POST /api/v1/federation/sandbox/:storeId/activate |
Runner-only manual activation for sandboxed stores (audited) |
GET /api/v1/federation/audit |
Runner-only audit tail (?limit, ?event) |
POST /api/v1/federation/evaluate |
Runner-only rules sweep |
Runner console (F3e) — the dashboard Node tab (runner-only) shows the federation section: discovery policy + graduation threshold + registries, peer table with status control and manual seed add, sweep trigger with results, sandbox queue with per-store progress and per-row Activate, plus an awaiting-activation banner and Node-tab badge, and the audit tail. Agent API keys can never touch federation state (session-only).
Agent integration (for ChatGPT / Claude / OpenClaw)
Shop owners and admins issue per-store API keys so AI agents can post, clear, and keep inventory current. Agents use the same endpoints as the dashboard — the key replaces the session cookie. There are no shopper-facing agent endpoints.
- Create a key (dashboard session required, shown once):
curl -s -X POST localhost:4000/api/v1/stores/<STORE_ID>/keys/ \
-H 'content-type: application/json' -H "cookie: $COOKIE" \
-d '{"name":"openclaw-inventory","scopes":["read","write_catalog"]}'
# → {"key":"gs_live_…","prefix":"…", …}
- Use it as an agent:
curl -s localhost:4000/api/v1/stores/<STORE_ID>/listings/?q=jacket \
-H "Authorization: Bearer gs_live_…"
curl -s -X POST localhost:4000/api/v1/stores/<STORE_ID>/listings/ \
-H 'content-type: application/json' -H "Authorization: Bearer gs_live_…" \
-d '{"title":"Wool Scarf","price":45,"fulfillmentType":"physical_shipped",
"shippingDetails":{"shippingRate":6.5,"shippingDestination":"US"}}'
Scopes: read · write_catalog · read_orders · manage_store
(staff members may only grant read). Keys support optional expiry,
per-minute rate limits (default 60, 429 when exceeded), and instant revoke.
Machine-readable spec: GET /docs/openapi.json.
Layout
apps/api Fastify + TypeScript API (src/{auth,node,stores,listings,uploads,api-keys,search,crypto})
apps/web Next.js dashboard + public storefront (App Router)
packages/shared zod env/types, WCAG + file-policy utils (used by both sides)
apps/api/migrations 001 core · 002 trust · 003 triggers+keys · 004 key prefix · 005 members updated_at · 006 payments · 007 shipping · 008 node peers · 009 directory · 010 wash signals · 011 policy · 012 peers actor-key repair · 013 search rings · 014 media formats · 015 search thumbs · 016 shipping methods + print gate · 017 shop imports · 018 migration fate · 019 shop SMTP · 020 threads · 021 read receipts · 022 webhooks · 023 federated links · 024 courier key · 025 matrix · 026 telegram · 027 discord · 028 slack · 029 rocketchat · 030 zulip · 031 telegram api-base · 032 node digital delivery · 033 listing digital assets · 034 download grants · 035 search digital disclosure
docker-compose.yml postgres :5440 · redis · meilisearch :7700 · optional Caddy gateway :8080
gateway/ edge-proxy contract: Caddyfile (default) + nginx-reference.conf + BYO guides (NPM/Pangolin/Netbird)
Requirements/ product + epic docs and protocol specifications
docs/ role guides (runner / owner / shopper), reference, real screenshots
AGENTS.md build memory, plan, and session log
Dashboard (apps/web)
Next.js 16 App Router + Tailwind v4 on :22001. Same-origin /api/v1/*
rewrites forward to the API, so the gs_session cookie needs no CORS setup.
The first account registered on a fresh node becomes the node runner and
alone sees the Node tab (identity + upload policies); everyone gets
Shops (store cards with product/volume stats, "Manage Products" →
Studio) and Studio (theming card + inventory table + product modal).
pnpm dev:web # dashboard with hot reload
pnpm test:e2e # 38 Playwright specs: auth, node, shops, studio, storefront, settings, buy, federation, search, media, fulfillment, cart, migration, email, qa, inbox, webhook, matrix, vendor-notifiers
test:e2e boots the API + web itself and truncates dev user data first,
so don't run it against a node you care about.
Product Studio (/dashboard/studio?store=)
Per-store theming + catalog management in the reference layout:
- Theming card — 4 color pickers (header bg, store bg, accent, text) with
hex readouts and a live WCAG 2.1 pill (
checkContraston text-vs-store-bg: emerald "WCAG AA Compliant (r:1)" vs amber "Contrast Warning"). Save →PATCH /stores/:idtheme. - Inventory table — Product Details (title, SKU, size/color badges) | Type | Price | Media (photo/video counts) | Actions (Edit; Delete hidden for staff — the API enforces admin-only deletes).
- Product modal — core fields (title, price, currency, fulfillment +
flat shipping when
physical_shipped, availability); 6-slot photo grid (slot 0 = Main cover, arrow reorder, set-cover,n / 6); video URL paste with YouTube/Vimeo/TikTok detection (max 4,n / 4); 6 attribute toggle cards serialized only when checked + non-empty.
Storefront + Settings (Phase B final)
Public storefront (U6) — /s/<slug> renders server-side (no login): themed
header/hero from stores.theme, responsive product grid; /s/<slug>/p/<id>
shows gallery + cover, YouTube/Vimeo embeds (TikTok/others stay external
links), attribute table, and stock + signature status. Only active stores
serve — unknown/inactive slugs 404. Server components hit the API directly
via API_INTERNAL_URL (lib/server-api.ts).
curl -s localhost:4000/api/v1/storefront/demo-shop # public profile
open http://localhost:22001/s/demo-shop # themed shop
Settings tab (U7) — per-store (?store=) team + agent keys + help:
- Members (owner/admin see it): list, invite a registered account by email with a role, change roles, remove. The last owner can't be demoted or removed; staff can't open the card at all.
- Agent keys: create with name + scope checkboxes + rate limit, reveal-once
amber panel with copy, revoke → badge. Staff may only create
readkeys. - Agent help: scopes table + curl snippets (
gs_live_…placeholder).
Buying (Epic 2 — guest checkout + mock gateway)
Shoppers need no accounts: the storefront product page has a Buy-now form
(email, quantity, shipping) → POST /stores/:id/orders/ creates a pending
order (totals = price × qty + shipping) → mock-pay (POST …/test-pay)
flips it paid → merchant marks it fulfilled from Settings → Orders.
curl -s -X POST localhost:4000/api/v1/stores/<STORE_ID>/orders/ \
-H 'content-type: application/json' \
-d '{"listingId":"<ID>","quantity":2,"buyerEmail":"sam@example.com",
"shippingAddress":{"country":"US"}}'
# → {"order": {"id":"…","status":"pending","totalAmount":43, …}, "intent": {…}}
Payment privacy rules (non-negotiable): shopper payment instruments are NEVER stored — no card numbers touch the database, ever (that would be PCI scope). Mock moves no card data; live processors will charge processor-direct (Stripe Checkout / PayPal buttons). Orders keep only buyer email + shipping address for fulfillment.
Merchant secrets: processor keys live AES-256-GCM-sealed in
store_payment_configs (same seal as store private keys), never logged or
returned. Webhooks (POST /api/webhooks/payment, HMAC-SHA256 per-store
secret over exact bytes) flip pending → paid; bad signatures 401 with
the order untouched.
Self-hoster guidance: set a strong PKEY_ENCRYPTION_KEY (the master key
— 64 hex chars) before first boot; rotate webhook secrets any time via
PUT …/payment-configs/mock {"rotateWebhookSecret": true} (revealed once);
keep test_mode on until a live processor adapter ships. mock works with
no credentials; stripe/paypal are stubbed until their adapters land.
Shipping: merchants add origin + zone/weight/price rows (PUT /stores/:id/shipping, e.g. US ≤1kg $4, US any $9, * any $15); physical
orders rate the table first and fall back to the listing flat rate. Carrier
APIs (UPS/FedEx/DHL) arrive later, one at a time.
Ship-ready fulfillment (methods + print-before-fulfill)
Physical goods ship with a paper trail. Merchants set up their preferred shipping methods in Settings (name + carrier + notes, one default — owner/admin only), then per order:
- Pick the method and print the packing slip (
POST /stores/:id/orders/:orderId/print {"methodId"}— defaults to the store default; reprints allowed). The slip shows the item, the method, the buyer address, and the print timestamp; the UI renders it in a dialog with print-only CSS (window.print()prints just the slip). - Mark fulfilled.
physical_shippedorders 400 until the slip is printed (shipment_printed_at); digital, subscription, and pickup orders fulfill as before. In-flight paid physical orders just need one print click — nothing is backfilled.
curl -s -X POST localhost:4000/api/v1/stores/<STORE_ID>/shipping/methods \
-H 'content-type: application/json' -H "cookie: $COOKIE" \
-d '{"name":"Standard Post","carrier":"USPS","isDefault":true}'
Cart (node-local, multi-seller checkout)
Shoppers can add to cart from any product grid or product page (Buy-now
stays as the express path). The cart lives in localStorage (gs:cart:v1,
guest-friendly, no accounts) and the header badge + slide-over drawer work
logged-out on every storefront.
Checkout (/checkout) groups lines per seller store and runs one leg per
listing — N items means N orders, even from the same seller (no order-lines
refactor). The shopper enters email + address once; every leg reuses it:
- Each line revalidates (price refresh, sold-out/withdrawn lines pruned with a notice).
- Legs run sequentially with per-leg status (Ready → Placing → Paying → Paid/Failed). Each seller charges separately on their own processor (no marketplace splitter).
- Partial failure is explicit: paid legs stay paid, failed legs show the error and retry failed only.
curl -s "localhost:4000/api/v1/stores/<STORE_ID>/orders/" -H "cookie: $COOKIE"
# → one paid order per cart line, visible in each merchant's Settings → Orders
Federated-ready seam: every cart line carries origin (local now,
federated later). A federated shop is just another cart line — the shopper
never cares which node it's on — and checkout runs a per-leg strategy.
Federated legs are fenced in their own group ("coming soon", nothing runs)
until the cross-node Order handshake lands.
Shop migration (node-to-node move)
A shop owner can move their whole shop to another node without the old runner's cooperation (exit can't be blocked). Scope is catalog + settings + live orders; the flow is export → import → trust review → go-live → old-shop fate:
- Export (old node, Settings → Move shop): a will-migrate / won't-migrate
preflight, then a one-time passphrase seals an encrypted
<slug>.gsbackup(manifest + checksums + payload: same Ed25519 keypair, listings with signatures, embedded photo bytes, live orders with print state, settings flags, trust-evidence pack). Owner-only. - Import (new node, Shops → Import): upload + passphrase → preflight
(slug clash → rename suggestion, media-vs-policy gate, missing-payments
warning) → atomic all-or-nothing insert. Duplicate manifests are rejected,
and the shop lands
sandbox+ pending review, never auto-active. - Trust review (new runner, Node → Migrated shops): the evidence pack (verified sales, disputes, wash summary) drives Carry reputation (active with carried counts) or Fresh sandbox (zeroed trust).
- Go-live checklist: re-enter processor keys (mock works immediately —
its webhook secret is freshly minted at import), re-invite the team
(accounts never travel), verify listings/media, test-buy. Migrated pending
orders carry no live intent: buyers re-pay with their order email
(
POST …/orders/:id/repay), then pay normally. - Old-shop fate (owner, Settings → Original shop fate): redirect stub
(recommended — "we've moved" page,
movedToon the actor doc, AP Move broadcast to peers, new orders refused with 410), delete (orders-first, cascades the rest), or leave running (double-fulfillment warning + optional pending-cancel here).
What never travels (stated at export and import): payment secrets, team
accounts, node policies/domain, cancelled orders, and media over the new
node's cap (clean abort, nothing created). One node holds one live copy of
an identity — re-importing live origin_uris is rejected as a fork.
Product media (photo conversion + thumbnails + lightbox)
Sellers can upload straight from their phones and cameras: .heic/.heif
(iPhone), .tif/.tiff, .bmp, and .avif are converted to JPEG on upload,
and .svg logos are rasterized to PNG (raw SVG is never served — it's code,
not pixels, and an XSS vector). .jpg/.jpeg/.png/.webp/.gif pass through
byte-identical so GIF animation, PNG transparency, and JPEG quality survive.
The original upload is discarded — converted bytes are canonical. RAW/PSD
stay rejected with an "export as JPEG first" message.
Every upload yields two derivatives: the display version (higher quality,
longest edge ≤2560px, JPEG q82) and a thumbnail (longest edge ≤800px,
JPEG q70). The upload response carries both URLs (url + thumbUrl), and
gallery images store the thumb alongside (thumbUrl, legacy rows fall back
to url). Bytes are sniffed before anything reaches disk — a .jpg full
of HTML fails with 400 — and post-conversion size is re-checked against the
runner's policy.
Privacy: EXIF is stripped on conversion (phone photos carry GPS) and orientation is applied first, so uploads never leak location metadata.
Shopper experience: grids and the gallery strip render thumbnails; clicking a photo opens the higher-quality display version in a lightbox (backdrop/Esc/←/→, counter, "View product" link) on the shop grid, the product gallery, and federated search hits.
Earned federation (Epic 3 F3c — sandbox + wash-trade)
New shops start sandboxed: no outgoing catalog sync until webhook-verified
sales earn them out. Every paid sale recounts (node_config.federation_sales_threshold,
default 5); reaching it flips the store active (trust.graduated audit),
falling back below it re-sandboxs a graduated store (trust.resandboxed).
Pre-existing active stores and probation/banned are never auto-touched —
sandbox is the automatic earned-gate, everything else is runner-managed.
Merchants mark troubled sales POST /stores/:id/orders/:orderId/dispute
(paid → disputed); disputed sales never count.
Wash-trade heuristics run at checkout and re-run at the paid transition;
flagged sales don't count toward graduation and show up as washFlags on
merchant orders: self_dealing (buyer email = shop member),
email_alias (same normalized base, different raw address),
intent_reuse, address_reuse, subnet_cluster (3+ buyer emails one
subnet/24h), velocity (6+ orders one subnet/24h). Emails normalize
(lowercase + plus-strip) before comparing.
Shopper privacy: only HMAC-SHA256(subnet/24-or-/48, WASH_TRADE_PEPPER)
is stored (orders.buyer_subnet_hash) — raw IPs never touch the DB, hashes
never leave it, and there is no device fingerprinting. Override
WASH_TRADE_PEPPER in production like SESSION_SECRET.
Federation rules (Epic 3 F3d — probation/block/recover)
The rules engine enforces node-level norms on peers (node_config
federation_* columns; defaults: max 15% restocking fee, min 14-day return
window, no seller-shipping requirement, max 2% complaint rate, auto-block
tampered). Each applied delivery and each tamper re-checks the sender:
short windows, high fees, and tamper evidence (inbox.invalid_signature /
received share from our own audit log — the only misconduct observable
locally) probation a peer (rules.probation, deliveries held — the inbox
already 403s non-active peers); tampering blocks outright when auto-block is
on (rules.blocked, critical). A clean sweep recovers probation → active
(rules.recovered); blocked stays sticky and pending stays curated —
both need the runner (F3e UI). Transitions only are audited.
curl -s -X POST localhost:4000/api/v1/federation/evaluate \
-H "cookie: $RUNNER_COOKIE"
# → {"evaluated":3,"results":[{"actorUri":"…","action":"probation",
# "violations":["return_window:7<14"]}, …]}
Disputes also maintain per-store dispute_count/complaint_rate in
node_trust_metrics (display only — no local auto-action).
Known constraint (fixed at the gateway layer): Next.js rewrites (dev and
next start) cannot forward large multipart bodies — an 11 MB upload 500s in the proxy before reaching the API. Upload traffic must enter via the edge gateway below, which routes/api/*+/media/*straight to Fastify. The E2E guard test therefore posts direct-to-API with the session cookie.
Federated search (Epic 4 — concentric rings)
One PG function serves every ring in a single round-trip (GIN FTS index, no
Meili hop): search_federated_catalog(query, store, transitive, price / fulfillment filters, limit/offset) returns rows stamped search_ring +
search_rank. The public endpoint wraps it with a ring summary:
- Ring 1 — the shop's own listings ("In Stock Here", emerald).
- Ring 2 — mirror listings from
activepeers at trust tier 1 ("Direct: <shop>", indigo). - Ring 3 —
activepeers at tier 2, only withincludeTransitive=true("Network: <shop>", violet).
curl -s "localhost:4000/api/v1/stores/<STORE_ID>/search?q=jacket&includeTransitive=false"
# → {"query":"jacket","totalResults":12,
# "ringSummary":{"ring1LocalCount":2,"ring2DirectPeersCount":10,"ring3TransitiveCount":0},
# "results":[{"title":"…","federationRing":1,"originStore":{…},"relevanceScore":0.6}, …]}
Only verified, InStock listings ever surface — tampered rows, sold-out
rows, and probation/blocked peers are invisible to every ring, and
ActivityPub Update/Delete purges reflect instantly (rows, not caches).
Remote hits carry absolute origin image URLs (lazy refs, never downloaded).
Runners promote peers between rings from the Node tab peer table
(Ring tier 1 Direct / 2 Network) or PATCH /api/v1/federation/peers/:id {"trustTier": 2}; new peers start at tier 1.
The public storefront (/s/<slug>) has a shopper search box with a network
opt-in toggle; local hits link to the product page, federated hits name
their origin store. (Remote checkout stays deferred — no cross-node Order
handshake until live processors land.)
Shop + node email (outbound SMTP — Messaging M0)
Buyer–seller messaging needs to send mail (guest verification links, reply
notices), so each shop gets its own SMTP settings with a node-level
fallback. Resolution is enabled shop row → enabled node row → fail loudly
(NO_SMTP_CONFIGURED, never pretend-sent):
- Shop card (Settings → Email): host, port, secure/TLS, username, password,
from email/name, enabled toggle, plus an inline test-send. Owner/admin
write; staff see the flags read-only. Passwords are AES-256-GCM-sealed
(same seal as payment configs) and never leave the server — reads carry a
hasPasswordflag, and blank-on-save keeps the sealed value. - Node card (Node tab → Node email): the runner-only fallback used when a shop has none of its own. Saving an empty host clears the fallback.
- Test sends verify the connection then deliver one message
(
POST …/email/test {"to"}); hostsstub.smtp.test(succeeds) andfail.smtp.test(400s) exercise both paths in tests with no network.
curl -s -X PUT localhost:4000/api/v1/stores/<STORE_ID>/email \
-H 'content-type: application/json' -H "cookie: $COOKIE" \
-d '{"host":"smtp.example","port":587,"secure":true,"fromEmail":"hello@shop.example","password":"…"}'
curl -s -X POST localhost:4000/api/v1/stores/<STORE_ID>/email/test \
-H 'content-type: application/json' -H "cookie: $COOKIE" \
-d '{"to":"you@example.com"}'
# → {"ok":true,"source":"store","messageId":"…"}
Buyer–seller messaging (MS1–MS7 ✅ complete)
Shoppers contact sellers from the item page; once an item is sold + paid the chat is buyer + seller only. Guests are email-identified and MUST verify before messaging; buyers never need node accounts.
- Threads core (MS1):
POST /stores/:id/questionscreates a thread and holds the first message as pending, then emails a verification link (/verify?token=, stateless HMAC, thread-scoped, 7-day expiry). Clicking it verifies the address and publishes the message; unverified guests get 403 on send. Per-store blocklists (exact normalized email +@domain) 403;require_account_for_questions(owner/admin toggle) 403s guests with a shopper-facing message; Redis spam windows (5/hr per email, 20/hr per IP) 429 with an explicitRATE_LIMITEDerror. - Item-page Q&A (MS2): the storefront product page has an ask form
(email + optional public display name, "Guest" fallback) and a public Q&A
section. Private by default — only
visibility=publicAND verified threads render, showing the first question + seller answers with display names only (emails never leave the server). Sellers reply + flip visibility from the Studio listing modal. - Post-sale threads (MS3): every order auto-provisions a private thread
(buyer = inquirer). Buyers reach it with
orderIdon the ask endpoint (email must match the order buyer; the verify email doubles as the access link). Order threads are excluded from public Q&A and refuse the public flip (ORDER_THREAD_PRIVATE); seller lists filter with?orderId=. - Merchant inbox (MS4): Settings → Inbox shows every thread with per-user unread badges (explicit mark-read on open), order badges, reply boxes, sender block, and make-public toggles — no external setup needed.
- Webhook notifier (MS5): per-store sealed config (URL + HMAC secret,
hasSecretflag only) fans every published message out as a signed POST (X-GetSelling-Signature: sha256=…over exact bytes + timestamp). Async with inline retries (3 tries, backoff) thenfailedin the attempt log — a dead endpoint never fails the message write.POST …/webhook/testsends a signed sample. - Federated inquiries (MS6): questions on federated listings route to
the origin node via ActivityPub (
Create+Note, verified through the existing inbox middleware). Both sides keep a thread: the shopper node holds + verifies locally then delivers; the origin projects a linked thread (pending-peer deliveries are held for review); seller replies route back into the shopper's thread. Tampered/forged deliveries 401 + audit; redeliveries dedupe to 202. - Matrix adapter (MS7): per-store sealed config (homeserver + access
token + room,
hasTokenflag only) posts every published message asm.text(PUT …/rooms/{roomId}/send/m.room.message/{txnId}, Bearer token), same fanout + retry + attempt-log contract as webhooks. Settings has a Matrix card (staff read-only) alongside the email card. - More vendors (Telegram / Discord / Slack / RocketChat / Zulip): the
same per-store pattern — sealed tokens/keys, enabled toggle, test button,
fanout on every published message with isolated retries + per-vendor
attempt logs. Telegram uses the Bot API (
sendMessage); Discord/Slack/RocketChat post to incoming-webhook URLs (the URL is the credential — reads show the host only); Zulip posts stream messages with Basic bot auth. Settings has one card per vendor (staff read-only).
# Ask about an item (guest), then verify via the emailed link
curl -s -X POST localhost:4000/api/v1/stores/<STORE_ID>/questions/ \
-H 'content-type: application/json' \
-d '{"listingId":"<ID>","email":"you@example.com","body":"Is this oak?"}'
# → {"threadId":"…","status":"pending","message":"Check your email …"}
Digital downloads (secure paid delivery)
Digital listings carry seller-set limits — max downloads per paid order
and a download window in hours starting at the first successful download
— disclosed to buyers before payment. Files are stored privately (never under
public /media/), and every download rechecks paid/fulfilled order state,
node policy, asset presence, and the per-order count/window.
- Node policy (runner):
GET/PUT /node/digital-deliveryenables or disables node-hosted files (default enabled) with separate extension/size policy. Disabling while digital listings exist requires an owner notice; a futureeffectiveAtschedules the change instead of applying it immediately. Disabled nodes fall back to manual external delivery. - Seller flow: Studio → digital listing → limits + private file upload (PDF/EPUB/ZIP/MP3/MP4 by default, magic-number sniffed). Listings without an asset stay purchasable with an explicit external-delivery notice.
- Buyer flow: the product page states both limits pre-payment; after payment the confirmation offers Download now, which mints a short-lived (15 min) order-bound token. Exhausted count, expired window, disputed/cancelled orders, and replaced assets all refuse delivery.
- Federation: signed
digitalDeliverymetadata (limits + asset version) travels with listing sync so mirrors disclose the same limits; bytes are only ever served by the origin node.
# Mint a download link for a paid order (buyer email must match)
curl -s -X POST localhost:4000/api/v1/orders/<ORDER_ID>/download-token \
-H 'content-type: application/json' -d '{"buyerEmail":"you@example.com"}'
# → {"token":"…","expiresAt":"…","remainingDownloads":5,…}
curl -OJ 'localhost:4000/api/v1/orders/<ORDER_ID>/download?token=…'
Edge gateway (optional Caddy — BYO proxy supported)
One public origin keeps the browser same-origin (no CORS): /api/* +
/media/* (+ /healthz, /docs/*) → API :4000, everything else → web
:22001. Full contract + guides for Nginx, Nginx Proxy Manager,
Pangolin, and Netbird live in gateway/README.md;
gateway/nginx-reference.conf is a drop-in server { … } block.
docker compose --profile gateway up -d # infra + Caddy on :8080
# browse http://localhost:8080 — uploads bypass Next.js entirely
Plain pnpm up / docker compose up -d leaves Caddy out — run your own
proxy with the same path table. Local pnpm dev + pnpm dev:web keeps
working without any gateway (Next rewrites cover small JSON).