SynapsVault-backend is the Express.js API powering the SynapsVault marketplace. It handles:
- Publisher registration — API key issuance, wallet linking
- Resource publishing — file upload to Supabase, link registration
- x402 payment verification — Stellar USDC micropayment validation
- On-chain registration — Soroban vault-registry contract calls
- Catalog serving — searched, filtered, paginated resource listings
- Admin panel — protected stats, force-delist, payment audit
- Webhook delivery — HMAC-signed event fanout to publisher endpoints
┌─────────────────────────────────────────┐
│ SynapsVault Backend │
│ │
Frontend ──────► │ Express app │
(React/Vite) │ ├── /resources CRUD + catalog │
│ ├── /verify-content AI originality │
│ ├── /publishers profile + keys │
│ ├── /registry on-chain status │
│ ├── /payments receipts + history │
│ ├── /admin protected stats │
│ ├── /health k8s probes │
│ └── /docs OpenAPI + Swagger │
│ │
│ Middleware stack │
│ ├── CORS + Security headers │
│ ├── Rate limiting (IP + wallet) │
│ ├── API key auth │
│ ├── Request signature auth │
│ ├── Audit log (all mutations) │
│ ├── Circuit breaker (Stellar RPC) │
│ └── Request tracing (OpenTelemetry) │
└──────────────┬──────────────────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
Supabase DB Stellar Horizon Soroban Contracts
(PostgreSQL) (x402 + USDC) (vault-registry
resources payment verify access-lease
payments tx submission subscription)
publishers
Requires Node.js 24 (npm 11) and PostgreSQL 16.
git clone https://github.com/SynapsVault/backend SynapsVault-backend
cd SynapsVault-backend
npm ci
cp .env.example .env
# Fill in the required values (marked "required" in .env.example). The server
# validates config on boot and exits listing anything missing.
npm run db:migrate # apply drizzle migrations
npm run dev # dev server with hot reload (default port 4021)
npm run typecheck
npm test
npm run build && npm start# Single container (listens on 3000; run migrations first)
docker build -t synapsvault-backend .
docker run --env-file .env -p 3000:3000 synapsvault-backend
# Full stack (API + Postgres), using values from .env
docker compose upThe OpenAPI 3 spec is served at /openapi.json, with Swagger UI at /docs.
| Method | Route | Auth | Description |
|---|---|---|---|
GET |
/health, /health/ready |
— | Liveness / readiness (DB + Soroban RPC) |
GET |
/metrics |
Bearer METRICS_TOKEN |
Prometheus metrics |
GET |
/resources |
— | Catalog: search, price/type/status filters, sort, pagination |
POST |
/resources |
API key | Publish a file (multipart) or link resource |
GET |
/resources/:id/meta |
— | Public preview |
GET |
/resources/:id |
x402 | Pay and access the resource |
DELETE |
/resources/:id |
API key | Delist own resource |
GET |
/resources/:id/register/prepare |
API key | Unsigned on-chain register tx |
POST |
/resources/:id/register |
API key | Submit signed register tx |
POST |
/resources/:id/price[/prepare] |
API key | Update on-chain price |
POST |
/resources/:id/ownership[/prepare] |
API key | Transfer on-chain ownership |
POST |
/verify-content |
x402 | AI originality check |
GET |
/agent/status |
— | Verification agent stats |
POST |
/publishers |
— | Register; returns the API key once |
GET |
/publishers/me[/resources|/analytics] |
API key | Profile, resources, earnings |
GET PATCH |
/publishers/me/rate-limit |
API key | View / override own rate limit |
GET PATCH |
/publishers/me/webhooks |
API key | Webhook URL, secret, events |
POST |
/publishers/me/webhooks/test |
API key | Send a signed test event |
GET |
/publishers/leaderboard |
— | Creator leaderboard |
GET |
/payments/:id/receipt |
— | Payment receipt |
GET |
/buyers/:address/payments |
— | Buyer purchase history |
GET |
/registry/status |
— | On-chain registry stats |
GET |
/admin/stats, /admin/audit |
Admin key | Platform metrics, payment audit |
POST |
/admin/delist/:id |
Admin key | Force delist |
Every error response has the same shape:
{ "error": "Resource not found", "code": "NOT_FOUND", "requestId": "…" }code is one of VALIDATION_ERROR, UNAUTHORIZED, FORBIDDEN, NOT_FOUND,
CONFLICT, PAYLOAD_TOO_LARGE, RATE_LIMITED, INTERNAL_ERROR,
UPSTREAM_ERROR, SERVICE_UNAVAILABLE or GATEWAY_TIMEOUT. Validation errors
add field-level details. The requestId matches the x-request-id header.
Publishers configure one endpoint with PATCH /publishers/me/webhooks
(webhookUrl, webhookSecret, webhookEvents, webhookEnabled). Events:
resource.purchased, payment.received, resource.listed, resource.delisted.
Deliveries are POST { event, timestamp, data }. When a secret is set,
X-SynapsVault-Signature holds the hex HMAC-SHA256 of ${timestamp}.${body},
where timestamp is the ISO-8601 value in X-SynapsVault-Timestamp and
body is the raw JSON body (#45). Each delivery also carries a unique
X-SynapsVault-Id header for deduplication.
Verification example (Node.js):
const crypto = require("crypto");
function verifyWebhook(req, secret) {
const timestamp = req.headers["x-synapsvault-timestamp"];
const signature = req.headers["x-synapsvault-signature"];
// Reject deliveries older than 5 minutes to prevent replays.
const age = Date.now() - new Date(timestamp).getTime();
if (age > 5 * 60 * 1000) throw new Error("Webhook too old");
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${req.rawBody}`) // rawBody = Buffer / string before JSON.parse
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"))) {
throw new Error("Invalid webhook signature");
}
}Failures are retried with exponential backoff (WEBHOOK_MAX_ATTEMPTS).
Private and loopback URLs are rejected.
Publisher mutation endpoints (POST /resources, DELETE /resources/:id, etc.)
support — and in production require — HMAC-SHA256 request signatures.
How it works
Every publisher receives a per-publisher signing secret (separate from the API key) on registration. The signing secret is the HMAC key, not the API key itself. This means an attacker who intercepts the API key cannot forge signed requests.
Signing a request (client side)
const crypto = require("crypto");
function signRequest({ signingSecret, method, path, timestamp, bodyHash, idempotencyKey }) {
const parts = [method.toUpperCase(), path, timestamp, bodyHash];
if (idempotencyKey) parts.push(idempotencyKey);
const canonical = parts.join("\n");
return crypto.createHmac("sha256", signingSecret).update(canonical, "utf8").digest("hex");
}
const timestamp = Math.floor(Date.now() / 1000).toString();
const bodyHash = crypto.createHash("sha256").update(rawBody, "utf8").digest("hex");
const signature = signRequest({
signingSecret: "<your signing secret>",
method: "POST",
path: "/resources/res-1/register",
timestamp,
bodyHash,
});
// Add to request headers:
// x-api-key: <your API key>
// X-Timestamp: <timestamp>
// X-Signature: <signature>Rotating the signing secret
POST /publishers/me/signing-secret/rotate
x-api-key: <your API key>
Returns { signingSecret, rotatedAt }. The new secret is shown once — store it
securely. The old secret is immediately invalid.
Environment variables
| Variable | Default | Description |
|---|---|---|
REQUIRE_REQUEST_SIGNATURE |
true (prod) / false (dev) |
Enforce HMAC signatures on publisher mutations (#44) |
SIGNING_SECRET_KEY |
— | AES-256 encryption key for signing secrets at rest. Required when REQUIRE_REQUEST_SIGNATURE=true (#42) |
SIGNATURE_MAX_SKEW_MS |
300000 |
Max clock skew (ms) for X-Timestamp |
Production behaviour (#44): REQUIRE_REQUEST_SIGNATURE defaults to true
in production. If it is explicitly set to false, the server logs a security
warning at boot. SIGNING_SECRET_KEY must be provided when signatures are
required.
All rate limits use a sliding-window algorithm. Responses include RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers. Blocked requests return 429 with { "error": "Too many requests", "code": "RATE_LIMITED", "retryAfterSeconds": N } and a Retry-After header.
| Limiter | Scope | Default | Env vars |
|---|---|---|---|
| Registration | IP | 10 per hour | RATE_LIMIT_REGISTER_IP_MAX, RATE_LIMIT_REGISTER_IP_WINDOW_MS |
| Verify-content | IP | 10 / min | RATE_LIMIT_VERIFY_IP_MAX, RATE_LIMIT_VERIFY_IP_WINDOW_MS |
| Verify-content | Wallet | 5 / hour | RATE_LIMIT_VERIFY_WALLET_MAX, RATE_LIMIT_VERIFY_WALLET_WINDOW_MS |
| Publish resource | IP | 20 / min | RATE_LIMIT_PUBLISH_IP_MAX, RATE_LIMIT_PUBLISH_IP_WINDOW_MS |
| Publish resource | Wallet | 10 / hour | RATE_LIMIT_PUBLISH_WALLET_MAX, RATE_LIMIT_PUBLISH_WALLET_WINDOW_MS |
| Publisher API routes | Per publisher | 60 RPM | PUBLISHER_RATE_LIMIT_RPM_DEFAULT, PUBLISHER_RATE_LIMIT_MAX_RPM |
Registration rate limit (POST /publishers): at most RATE_LIMIT_REGISTER_IP_MAX (default 10) registrations per IP within RATE_LIMIT_REGISTER_IP_WINDOW_MS (default 1 hour). This prevents bulk account and API-key creation from a single IP address.
Publishers can inspect and override their own per-publisher limit via PATCH /publishers/me/rate-limit (up to PUBLISHER_RATE_LIMIT_MAX_RPM).
See .env.example for the full list and descriptions.
When deploying to Railway, set TRUST_PROXY=1 so Express rate limits use the
client IP forwarded by Railway. Proxy trust is disabled outside production.
Every push and PR to main/dev runs ci.yml:
- Typecheck & Test:
npm ci,npm run typecheck,npm test,npm run build - Docker build & smoke test: builds the image, applies migrations to a Postgres service, boots the container and checks key endpoints.
catalog-seed.yml runs weekly (or on
demand) to seed a testnet catalog and verify it end to end.
| Repo | Description |
|---|---|
| SynapsVault-frontend | React UI |
| SynapsVault-contracts | Soroban contracts |
MIT © 2025 Busiii-adetiba
Outside localhost the Postgres server certificate is verified (rejectUnauthorized: true). If your database uses a private CA, set DATABASE_CA_CERT to the PEM-encoded CA bundle (literal \n sequences are accepted for single-line env vars).