Architecture
How Tollgate is put together, for people who want to change it. Everything below is read straight from the source; file paths are relative to the repo root. If you change behavior, change the matching spec in specs/ in the same commit.
Three live diagrams accompany this page (each URL also serves .svg and .json variants):
Component map
One Node 20 process, one Postgres database. No queues, no workers, no Redis.
src/server.ts boot: migrate -> seed -> listen -> start scheduler
src/app.ts buildApp(): Express wiring, middleware order
src/config.ts all env vars, with dev defaults
src/db.ts pg pool, query(), tx(), migrate()
src/migrations.ts forward-only SQL migrations (currently 2)
src/x402/types.ts x402 v1 + v2 wire types, header codecs, CAIP-2 mapping
src/x402/engine.ts build402Body(), buildRequirements(), settle()
src/x402/wallet-provider.ts WalletProvider interface + MetaWalletStub + selection
src/x402/real-wallet-provider.ts FacilitatorWalletProvider (production)
src/x402/facilitator.ts /facilitator/verify /settle /supported
src/scheduler.ts in-process 1-minute tick for scheduled agent runs
src/notifications.ts insert/list/mark-read on the notifications table
src/demo/agent-runner.ts briefing-bot: real x402 purchases against our own /r/ endpoints
src/routes/ content, pages, dashboard, agents, api, publisher-api, docs
src/middleware/security.ts rate limit, CSRF, security headers
src/views/ server-rendered HTML (no client framework)
buildApp() in src/app.ts mounts routers in a deliberate order. Machine surfaces come first, before any session or CSRF middleware, because they authenticate with payment headers or Bearer keys, never cookies:
securityHeaders, then static files frompublic/(1 hour cache)/facilitator(the in-app x402 facilitator)/rwith a 240 req/min token bucket,/apiwith 120 req/minpublisherApiRouter(Bearertg_pk_...andtg_sk_...endpoints, JSON bodies up to 4 MB)attachUser(session cookie lookup), then content, api, and docs routers/loginand/signupwith a 15-capacity, 10/min-refill bucketexpress.urlencoded, CSRF token minting on HTML GETs,csrfProtect, then the browser routers (pages, dashboard, agents)- A 404 handler that redirects to
/, and a final error handler that guarantees a 500 JSON response instead of a hung request
Postgres schema
Migration 1 (src/migrations.ts) creates the core tables:
| Table | Holds |
|---|---|
users |
publishers: email, bcrypt hash, balance_micro (earnings in micro-USDC) |
sessions |
opaque session tokens with expiry |
resources |
paid content: slug, kind, price_micro (min 1000, that is $0.001), files JSONB (path to {content, contentType}) |
agents |
custodial wallets: api_key (tg_sk_...), balance_micro, spending rules (max_per_request_micro, daily_budget_micro, ask_above_micro, allowed_domains) |
payments |
the ledger: every attempt, statuses paid, pending_approval, approved, blocked, failed, plus receipt_id (tg_xxxx), scheme, network, tx_ref |
payouts |
withdrawal queue per publisher |
agent_runs |
demo runner logs as JSONB, polled by the console UI |
Migration 2 adds:
users.api_key(tg_pk_..., backfilled and NOT NULL): publisher keys for the CLI and REST APIusers.payout_addressandpayouts.address: withdrawals go to a concrete USDC address, andpayouts.statusnow defaults topendinginstead ofsentagents.scheduleandagents.last_scheduled_at: the scheduler's state- the
notificationstable: kindsapproval_needed,payment_blocked,payout,run_done, with an unread index
migrate() in src/db.ts tracks applied ids in schema_migrations and runs each pending migration inside a transaction. Migrations are forward-only; there is no down path. src/db.ts also parses Postgres BIGINT to JS Number (safe because micro-USDC amounts stay far below 2^53) and enables TLS only when the DATABASE_URL host matches Railway's public proxy.
The money flow: custodial vs on-chain
Tollgate accepts two x402 schemes, advertised together in every 402 response (buildRequirements() in src/x402/engine.ts):
Custodial: tollgate-credit on network tollgate
This is the scheme the built-in agents use and it is fully functional end to end. The platform holds all balances in Postgres: agents hold agents.balance_micro, publishers accumulate users.balance_micro. The payload is just { "apiKey": "tg_sk_..." }.
settleCustodial() enforces the spending rules server-side, in this order, and writes every attempt to the payments ledger:
- Domain allowlist: blocked attempts are recorded with reason
domain_not_allowed - Ask-above: a price over
ask_above_microcreates one deduplicatedpending_approvalrow and notifies the owner; a storedapprovedrow for the same agent, resource, and amount releases exactly one purchase, and an explicit approval trumps the per-request cap - Max per request: only checked when no approval is being consumed
- Daily budget: a hard stop, even for approved purchases, computed as the sum of today's
paidrows
Settlement itself (settleFunds()) is a single Postgres transaction: SELECT ... FOR UPDATE on the agent balance, debit agent, credit publisher, insert (or promote) the paid payment row with receipt tg_xxxx and tx_ref credit:<receipt>. Insufficient funds rolls back and records a failed row.
On-chain: exact on an EVM network
USDC via EIP-3009 transferWithAuthorization, quoted with payTo = PAY_TO_ADDRESS and asset = USDC_ADDRESS (Base Sepolia USDC by default). settleOnChain() runs walletProvider.verify() then walletProvider.settle(); on success the payment is mirrored into the custodial ledger, meaning the publisher's balance_micro is credited immediately and the payments row carries the real transaction hash. Real payouts reconcile against the chain later.
Treasury model and the payout queue
Publisher earnings live in users.balance_micro until withdrawn. POST /payouts (src/routes/dashboard.ts) requires a saved payout_address (validated as 0x plus 40 hex), then in one transaction zeroes the balance and inserts a payouts row with status pending and the target address. The app does not broadcast the transfer itself: withdrawals sit in the queue as pending treasury settlement, and the user gets a payout notification saying exactly that. This is deliberate honesty; nothing pretends a chain transfer happened when it did not.
The provider abstraction
WalletProvider (src/x402/wallet-provider.ts) is the on-chain settlement boundary. The contract, spelled out in specs/11-wallet-provider-interface.md:
verify(payload, requirements)must check that the EIP-712 signature recoversauthorization.from, the value covers the price,toequalspayTo, the time window is valid, and the nonce is unused. It must never move funds.settle(payload, requirements)submitstransferWithAuthorizationand returns the tx hash. It must be idempotent per authorization nonce.
Two implementations exist, selected once at module load by X402_PROVIDER:
FacilitatorWalletProvider (src/x402/real-wallet-provider.ts, the default, X402_PROVIDER=facilitator). Verification is two-layered. Layer 1 is local and real cryptography: recoverTypedDataAddress from viem recovers the EIP-712 TransferWithAuthorization signer and it must match authorization.from, alongside recipient, value, and time-window checks; no network needed. Layer 2 delegates verify and settle to a live x402 facilitator (default https://x402.org/facilitator, override with X402_FACILITATOR_URL) using v2 wire shapes with CAIP-2 network ids, a 20 second timeout, and fail-closed behavior: if the facilitator is unreachable, the payment is rejected with facilitator_unreachable, never silently accepted.
MetaWalletStub (in src/x402/wallet-provider.ts, X402_PROVIDER=stub). Structural validation only, an in-memory nonce set, and a deterministic fake tx hash derived from sha256 of the nonce. It exists solely so the integration gate runs hermetically offline. Do not deploy it.
# run the test gate against the stub, no network needed
X402_PROVIDER=stub npm run gate
The in-app facilitator (src/x402/facilitator.ts, mounted at /facilitator) exposes the standard surface, GET /supported, POST /verify, POST /settle, backed by the same provider. External resource servers could point at it; Tollgate's own content route calls the engine directly instead, which uses the same provider underneath. /supported mirrors the live x402.org response shape and advertises both v1 and v2 kinds for every provider network plus tollgate-credit.
The scheduler and notifications
startScheduler() (src/scheduler.ts) is called after listen() and ticks every 60 seconds in-process. The schedule grammar on agents.schedule:
'': manual onlydaily HH:MM: once per day at that UTC timeevery N: every N minutes, minimum 5
A due agent's last_scheduled_at is claimed with an UPDATE before the run starts, so a crash-restart loop cannot double-run. The tick then fires runDemoAgent() and polls the run's final state for up to two minutes to send a run_done notification. Notifications are plain rows in the notifications table; the UI polls the unread count, there is no push channel.
The demo runner (src/demo/agent-runner.ts) is not a simulation: it makes real HTTP requests against this deployment's own /r/ endpoints, receives real 402s, pays with a real X-PAYMENT header through the full engine (rules, ledger, receipts), and streams its log into agent_runs. Only the pacing between steps is theatrical.
Security layers
All of it lives in src/middleware/security.ts plus the mounting order in src/app.ts.
Rate limits. An in-memory token bucket per keyPrefix:ip. Content (/r) gets capacity 240 refilling 240/min, the JSON API (/api) 120/120, and auth (/login, /signup) capacity 15 refilling 10/min. Exhausted buckets answer 429 {"error":"rate_limited","retryAfterSeconds":60}. Stale buckets are swept every 5 minutes to bound memory. trust proxy is set to 1 so req.ip is the real client behind Railway's proxy.
CSRF. Double-submit cookie: a tg_csrf cookie (SameSite=Lax) is minted on HTML GETs, and every form-encoded POST must carry a matching token in the _csrf body field or x-csrf-token header. Machine surfaces are exempt by construction: they are mounted before csrfProtect and carry no session cookie, so CSRF does not apply to x402 payments, the facilitator, or the Bearer-authenticated API. Session cookies themselves are HttpOnly; SameSite=Lax, plus Secure in production.
Headers. Every response gets X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, and a Permissions-Policy that disables camera, microphone, and geolocation. x-powered-by is disabled.
Money-path invariants. Balance mutations only happen inside tx() with row locks, the content route resolves the file before charging (never charge for a miss), and the engine records blocked and failed attempts so the ledger is a complete audit trail, not just a list of successes.
Deployment
Production runs on Railway: a managed Postgres 16 service plus one Node 20 web service built by Nixpacks. railway.json pins the contract: build npm run build, start npm start, healthcheck /healthz with a 120 second timeout, restart on failure with max 5 retries.
/healthz (src/routes/api.ts) runs SELECT 1 and answers { ok: true, service: 'tollgate', db: 'up' } or a 503 when the database is down.
Boot order in src/server.ts: migrate() applies pending migrations on every boot (idempotent via schema_migrations), seedDemo() runs unless SEED_DEMO=false, then the app binds 0.0.0.0:$PORT (Railway injects PORT; binding localhost fails the healthcheck) and the scheduler starts. A fatal boot error exits with code 1 so Railway's restart policy takes over.
Configuration is entirely env vars (src/config.ts): PORT, DATABASE_URL, APP_SECRET, BASE_URL, X402_NETWORK (default base-sepolia), USDC_ADDRESS, PAY_TO_ADDRESS, SEED_DEMO, X402_PROVIDER, X402_FACILITATOR_URL.
Verification gates around every deploy:
# pre-deploy, local RED/GREEN: typecheck, unit, integration/e2e, build
npm run gate
# deploy
railway up --detach
# post-deploy: read-only checks plus one real production x402 purchase
npx tsx scripts/smoke.ts https://web-production-095eb.up.railway.app
The full provisioning transcript and the Azure migration plan live in specs/13-deployment-railway.md.