Federated Online Selling with cryptographic protections, earned federation, and shop federation and defederation rules.
  • TypeScript 95.5%
  • JavaScript 3.4%
  • PLpgSQL 0.9%
  • Dockerfile 0.1%
Find a file
2026-10-27 07:51:13 -05:00
apps Sandbox activation: runner Activate action, Node alert/badge, owner notices 2026-10-27 07:51:13 -05:00
docker Docker 0.1.0: image + prod compose + env docs (COOKIE_SECURE override) 2026-10-26 08:19:56 -05:00
docs Sandbox activation: runner Activate action, Node alert/badge, owner notices 2026-10-27 07:51:13 -05:00
gateway Edge gateway (Option 1): optional Caddy + BYO-proxy contract 2026-10-13 15:37:16 -05:00
packages/shared Secure digital downloads + default Postgres 17 in compose 2026-10-27 05:49:34 -05:00
Requirements Phase A backend + Phase B slice 1 dashboard (U0-U2) 2026-10-13 10:22:48 -05:00
scripts Docs: role guides, real screenshots, and link validation 2026-10-26 15:20:58 -05:00
.dockerignore Docker 0.1.0: image + prod compose + env docs (COOKIE_SECURE override) 2026-10-26 08:19:56 -05:00
.env.example Secure digital downloads + default Postgres 17 in compose 2026-10-27 05:49:34 -05:00
.env.prod.example Secure digital downloads + default Postgres 17 in compose 2026-10-27 05:49:34 -05:00
.gitignore Docker 0.1.0: image + prod compose + env docs (COOKIE_SECURE override) 2026-10-26 08:19:56 -05:00
AGENTS.md Sandbox activation: runner Activate action, Node alert/badge, owner notices 2026-10-27 07:51:13 -05:00
docker-compose.prod.yml Sandbox activation: runner Activate action, Node alert/badge, owner notices 2026-10-27 07:51:13 -05:00
docker-compose.yml Secure digital downloads + default Postgres 17 in compose 2026-10-27 05:49:34 -05:00
Dockerfile Sandbox activation: runner Activate action, Node alert/badge, owner notices 2026-10-27 07:51:13 -05:00
package.json Docs: role guides, real screenshots, and link validation 2026-10-26 15:20:58 -05:00
pnpm-lock.yaml Messaging M0: per-shop + node SMTP (mailer, API, cards, docs) 2026-10-25 14:14:51 -05:00
pnpm-workspace.yaml Phase A backend + Phase B slice 1 dashboard (U0-U2) 2026-10-13 10:22:48 -05:00
README.md Sandbox activation: runner Activate action, Node alert/badge, owner notices 2026-10-27 07:51:13 -05:00
Reality_Check.md Epic 3 F3f: wrap-up verify + Reality_Check.md (Epic 3 COMPLETE) 2026-10-16 07:36:00 -05:00
tsconfig.base.json Phase A backend + Phase B slice 1 dashboard (U0-U2) 2026-10-13 10:22:48 -05:00

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 .gsbackup export → 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:

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 when GET :4000/healthz returns 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 uploads volume (/data/uploads in-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.

  1. 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":"…", …}
  1. 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 (checkContrast on text-vs-store-bg: emerald "WCAG AA Compliant (r:1)" vs amber "Contrast Warning"). Save → PATCH /stores/:id theme.
  • 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 read keys.
  • 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:

  1. 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).
  2. Mark fulfilled. physical_shipped orders 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:

  1. Each line revalidates (price refresh, sold-out/withdrawn lines pruned with a notice).
  2. Legs run sequentially with per-leg status (Ready → Placing → Paying → Paid/Failed). Each seller charges separately on their own processor (no marketplace splitter).
  3. 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:

  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. Old-shop fate (owner, Settings → Original shop fate): redirect stub (recommended — "we've moved" page, movedTo on 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 active peers at trust tier 1 ("Direct: <shop>", indigo).
  • Ring 3 — active peers at tier 2, only with includeTransitive=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 hasPassword flag, 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"}); hosts stub.smtp.test (succeeds) and fail.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/questions creates 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 explicit RATE_LIMITED error.
  • 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=public AND 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 orderId on 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, hasSecret flag 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) then failed in the attempt log — a dead endpoint never fails the message write. POST …/webhook/test sends 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, hasToken flag only) posts every published message as m.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-delivery enables or disables node-hosted files (default enabled) with separate extension/size policy. Disabling while digital listings exist requires an owner notice; a future effectiveAt schedules 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 digitalDelivery metadata (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).