Skip to content

PayPal sandbox only. No real money moves.

OperatorsSafety

Threat model

Who can do what, where the trust boundaries are, and what each secret protects. Names only, never values.

Unverifieddescribed, but not yet checked

Page last changed 7 Nov 2026 (from the git history)

A threat model answers three questions: what are we protecting, who might attack it, and what stops them. This page is a design map of GAVEL, read from the code. It is not an audit. The security checklist has 18 items and none is signed off yet (see the security review).

Everything here is the PayPal sandbox. No real money moves.

Threat model: trust boundaries and secrets

Sandbox only
Threat model: trust boundaries and secretsThree zones. Left: browsers and an operator, outside the host. Middle: the GAVEL host, a single process. Edge checks come first, then four doors: token, audio, admin and webhook. Behind the doors sit the room, engine and ledger, the payments saga and the voice bridge, then storage. Right: the PayPal sandbox, the speech providers and GitHub Actions. Each arrow that crosses a boundary names the credential it carries: a signed token, the admin key, PayPal OAuth keys, a signed webhook event or a speech API key. A stranger with a bad link is refused at the door.OUTSIDE: PEOPLEGAVEL HOST: ONE NODE PROCESSOUTSIDE: SERVICESBidder browserAuctioneer browserScreen displayOperatorholds the admin keyStranger with a linkrefused at the doortoken oradmin keybad linkEdge checksToken doorTOKEN_SECRETAudio doortoken in 5 sAdmin doorADMIN_KEYWebhook doorPayPal signatureINSIDE: NOTHING RUNS BEFORE A DOOR PASSESRoom, engine, ledgerengine, ledgerVoice bridgespeech to bidsPaymentsholds, sagaStoragefiles on a persistent disk or PostgresBidders see their own ceiling only. Audit rows for others carry no transcript.In production the host refuses weak secrets, fake payments and the demo routes.PayPal sandboxapi-m.sandbox.paypal.comSpeech providersDeepgram, Groq (if keyed)GitHub Actionssandbox secretssigned eventOAuth keysaudio + API key

Scroll the figure sideways, or open it full size.

Show secret:

Everything on the map. Pick a secret to see only what it protects.

Hover, focus or tap a box for what it checks and which file does it. Only secret names appear here, never values.

Read from packages/server/src/auth (token.ts, rotation.ts, authorize.ts), packages/server/src/http/ops.ts, http/auctions.ts, http/security-headers.ts, packages/server/src/config.ts, host/audio-ws.ts, packages/payments/src/guard.ts, render.yaml and docs/SECURITY-REVIEW.md. This is a design map, not an audit: all 18 items of the security checklist are still OPEN and no item has been signed off. Everything here targets the PayPal sandbox.
Text version of this diagram
  • Bidder browser. Joins with an invite token (24 h, single use) and then a session token (4 h). It sees its own ceiling and nobody else's: redactForViewer in auth/authorize.ts drops any ceiling that does not name the viewer.
  • Auctioneer browser. Holds an auctioneer token (12 h). It can control the auction and enter bids for bidders. It cannot create auctions or mint a successor link: both need the admin key (http/auctions.ts, http/auctioneer-token.ts).
  • Screen display. Holds a screen token (12 h). A screen never receives ceilings or per-bidder frames, and sees public audit rows only.
  • Operator. Sends the key in the x-admin-key header. With it: create an auction, rotate or revoke auctioneer links, list rooms, void all holds or release a review (/ops/...), read metrics, export the ledger and replay bundle. This is the most powerful secret.
  • Stranger with a link. A token for another auction, an expired, revoked or edited one, or the wrong kind is refused (verifyToken returns unauthorized). An invite that was already used answers invite_used. A leaked auctioneer link is killed by the rotate endpoint, which raises a per-auction floor.
  • Edge checks. Origin allowlist (PUBLIC_URL plus CORS_ORIGINS) on HTTP and both sockets, per-source rate limits (RATE_*, behind TRUSTED_PROXY_HOPS), body size caps and baseline headers: CSP default-src 'none', HSTS, nosniff, no referrer (http/security-headers.ts).
  • Token door. A token is payload.signature, HMAC-SHA256 with TOKEN_SECRET. The server checks the signature in constant time, then audience, lifetime, kind, identity binding and the revocation list. During a rotation TOKEN_SECRET_PREVIOUS is accepted until TOKEN_SECRET_PREVIOUS_UNTIL. A secret under 32 bytes stops a production boot.
  • Audio door. The /audio socket takes the auctioneer token as its first message within 5 seconds, never in the URL, then enforces rate, size and duration limits. Audio before auth closes the socket with 4401.
  • Admin door. The header value and ADMIN_KEY are hashed with SHA-256 and compared with timingSafeEqual (http/ops.ts). In production the key must be at least 24 characters and must differ from TOKEN_SECRET (config.ts).
  • Webhook door. The raw body is verified before anything is read from it, through PayPal's own verification endpoint with PAYPAL_WEBHOOK_ID (G4 PASS on real events; tampered-event rejection is covered against the fake only). The certificate URL host must be paypal.com or a subdomain. Bodies over 256 KiB are refused and only an event id and an outcome are logged.
  • Inside the host. One Node process. Nothing here runs before a door check has passed. The engine still validates every bid, whoever sent it.
  • Room, engine, ledger. The engine accepts or rejects every bid and appends it to the hash-chained ledger. A role table (auth/authorize.ts) lists which role may do which operation; anything not listed is denied.
  • Payments. Holds, settlement saga and journals. Only the sandbox API host over https is allowed (payments/src/guard.ts), PAYPAL_ENV must be sandbox, and fake payments are refused in a production-like setting. The admin key can void all holds or release a review through /ops; the audit row names the actor admin.
  • Voice bridge. Turns speech into bids that still go through the room gate and the engine. The bidder comes from the paddle that was heard, never from the token. Drop records leave the transcript out.
  • Storage. Room log, ledger and settlement journals in files on a persistent disk (/var/data in render.yaml) or in Postgres through DATABASE_URL. The memory store is refused in production unless explicitly allowed.
  • PayPal sandbox. Reached with OAuth client credentials (PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET) and nothing else. It sends signed webhook events back. The host name is fixed to api-m.sandbox.paypal.com; a CI check fails the build if source names the live host.
  • Speech providers. Audio leaves the host only when VOICE_STT names Deepgram or Groq and its key (DEEPGRAM_API_KEY, GROQ_API_KEY) is set. The default build plays cached transcripts and sends nothing. The product tells people: "Audio is sent to a speech service to read bids. Do not speak personal data."
  • GitHub Actions. Gate and nightly workflows read the sandbox app and buyer secrets from repository secrets (names such as PAYPAL_CLIENT_ID, SANDBOX_BUYER); values are masked in logs and never recorded. The gate workflows run in the sandbox-nightly environment and only from main.

Boundary crossings: browsers and the operator reach the host over HTTPS and WebSocket carrying a signed token or the admin key. The host reaches PayPal with OAuth client credentials. PayPal reaches the host with a signed webhook event. The host reaches a speech provider with an API key only when one is configured. GitHub Actions reaches PayPal with the sandbox secrets.

For a beginner: the two secrets that matter most

Think of GAVEL as a building with two kinds of keys.

TOKEN_SECRET is the stamp the host uses to sign tickets. When you join an auction you are given a link that contains a ticket (a token). The ticket says who you are, which auction it is for, what you may do and when it expires. The host signs it with TOKEN_SECRET, so it can later tell a real ticket from a forged or edited one.

  • Only the host knows the stamp. Browsers carry tickets, never the stamp.
  • If someone learns TOKEN_SECRET they can print a valid ticket for anyone, including an auctioneer. That is why it must be at least 32 bytes in production and why it is rotated with a grace period (below).
  • Tickets are short lived: invite 24 hours (single use), session 4 hours, auctioneer 12 hours, screen 12 hours.

Why two secrets and not one

A ticket can be copied from a browser or a screen share. The admin key never goes to a browser. Keeping them separate means a stolen ticket can never create auctions or move money, and a stolen admin key does not let anyone forge tickets. Both can be rotated on their own.

Every secret, by name

The values live only in the host's environment (Render marks them as not synced from the repository) and in GitHub repository secrets. They are never committed, logged or shown.

Prop

Type

Trust boundaries

A trust boundary is a line where data passes from something we do not control to something we do. Every crossing has a check.

CrossingWhat is trustedWhat is checkedWhere
Browser to host (HTTP and WebSocket)NothingOrigin allowlist, rate limits, body caps, then a signed token with audience, lifetime, kind and revocationpackages/server/src/auth/token.ts, host, http
Operator to hostThe holder of ADMIN_KEYConstant-time key check on every ops, export, create and rotate routepackages/server/src/http/ops.ts
Audio socketThe auctioneer token, onceToken as the first message within 5 seconds; rate, size and duration limits; the bidder comes from the paddle heard, not the tokenpackages/server/src/host/audio-ws.ts
PayPal to host (webhook)Nothing in the body until verifiedRaw body verified through PayPal's endpoint; certificate host must be paypal.com; 256 KiB cappackages/payments/src/webhooks/verify.ts, http/webhook-guard.ts
Host to PayPalThe sandbox API host onlyExact host match on api-m.sandbox.paypal.com over https; PAYPAL_ENV must be sandboxpackages/payments/src/guard.ts
Host to speech providerThe provider, for the audio it is sentOnly when VOICE_STT names it and its key is set; the product warns people not to speak personal datapackages/server/src/voice/stt.ts
Host to disk or PostgresThe host's own storageFiles on one persistent disk, or a database; memory store refused in production unless allowedpackages/server/src/config.ts, render.yaml
CI to PayPalRepository secretsGate workflows run in the sandbox-nightly environment and only from main.github/workflows/gate-g*.yml

What an attacker can try

Rotating a secret

TOKEN_SECRET. Set TOKEN_SECRET_PREVIOUS to the current value and TOKEN_SECRET_PREVIOUS_UNTIL to a deadline in epoch milliseconds. Set TOKEN_SECRET to the new value and restart. Tokens signed with either are accepted until the deadline. After it, remove the two PREVIOUS settings and restart.

ADMIN_KEY. Change it in the host environment and restart. Update the GitHub secret that holds the same value if a workflow uses it.

PayPal credentials. Rotate in the PayPal developer dashboard first, then update the environment.

A leaked auctioneer link. Call the auctioneer rotate endpoint with the admin key. Details are in the operator runbook.

What is not covered

Where this lives in the code

Edit on GitHub

Last updated on