Security
who holds what.
The server never holds a trading key and never signs an order. This page lists what it does hold and how each piece is protected, so you can explain it to your users.
01Custody
- Arena orders are signed in your runner tab by an agent key generated there (sessionStorage only), approved once by your master wallet. The agent can trade and cancel, cannot withdraw, and expires.
- Ride orders are signed in the viewer's tab with the viewer's own agent key.
- Paper leagues involve no keys and no signing at all.
- Payments go to your Stripe account or your wallet; the server only verifies them. Card access is granted only from a Stripe webhook whose signature the server checks with your signing secret, once per event, for the user who started the checkout and only when the amount paid covers the plan's price set in your console. A USDC payment is granted only for a confirmed transfer to your address, made after the member's checkout, of the exact amount tagged to that checkout (or at least that amount from the member's signed-in wallet); each transaction counts once.
02What the server holds
| Item | Protection |
|---|---|
| Model, Stripe, Telegram bot and RPC keys | Environment, or the console's store encrypted with AES-256-GCM under ADMIN_SECRET_KEY. Write-only; never sent to browsers. Model keys go only to the provider's own host unless ALLOW_CUSTOM_LLM_BASE_URL=1. |
| Console accounts | scrypt passwords of 12+ characters, lockout after 5 failures per email, optional or required two-factor, 12 h httpOnly SameSite=Strict cookie, a CSRF header on every write, append-only audit log with masked addresses. |
| Operator token | Compared in constant time; wrong tokens are slowed and then refused per client IP. |
| Viewer sessions and members' webhook secrets | Signed and encrypted with PIT_SECRET. Member webhooks are resolved, validated and pinned to their IP on every send (no private addresses unless ALERTS_ALLOW_PRIVATE=1). |
| Viewer sign-in | Sign-In with Ethereum messages are bound to your domain (PUBLIC_URL when set). Plain wallet signatures are checked offline; smart-contract wallets only through the read-only isValidSignature call on the RPC you list in SIWE_RPC_URLS. |
| Country and client IP | Trusted only from a proxy that sends TRUSTED_PROXY_SECRET. |
Every page is sent with a Content-Security-Policy that has no 'unsafe-inline' and no 'unsafe-eval': scripts and styles load only from your own domain (plus Google Fonts for the typefaces).
The two styles PIT writes into a page itself, the first-paint accent colour and the share page's few rules, are allowed by their exact SHA-256 hash; the operator console allows the one style its command palette inserts through a fresh nonce on every response.
The accent colour you change in the console is applied as a constructed stylesheet.
The server never serves source maps (*.map).
server/hardening.test.ts and server/money/money.test.ts check all of this on every npm test.
03Secrets at rest: ADMIN_SECRET_KEY
ADMIN_SECRET_KEY (32 bytes: 64 hex characters or base64) is the key of the console's secret store in admin.sqlite. It never leaves the server's environment and is never written to disk by PIT.
| Stored in admin.sqlite | Protection |
|---|---|
| Everything entered in Integrations: AI keys, Stripe secret key, Stripe webhook secret, Telegram bot token, USDC RPC URL | Encrypted with AES-256-GCM under ADMIN_SECRET_KEY, a fresh random IV per value, the field name bound as associated data (packages/admin-kit/server/crypto.ts) |
| Each user's two-factor (authenticator) secret | Encrypted the same way |
| Passwords | scrypt hashes, not encrypted with the key |
| Sessions, the setup code, two-factor recovery codes | Only a SHA-256 hash is stored |
| First owner | Created only with the one-time ADMIN_SETUP_TOKEN from the environment (compared in constant time) or, when that is unset, the one-time setup code from the server log. Without either, setup is refused; once an owner exists, setup answers 409. |
| Settings, audit log, history | Plain (they hold no secrets; addresses are masked in the audit log) |
A value set as an environment variable (for example ANTHROPIC_API_KEY) is never stored; it wins over and locks the console field. PIT_SECRET is a separate key: it signs viewer sessions and encrypts members' own Discord and webhook addresses in pit.db.
If ADMIN_SECRET_KEY is lost or changed
- The server still starts. It only checks the format; a malformed key stops it with
[pit] operator console: ADMIN_SECRET_KEY must be 32 bytes: openssl rand -hex 32. - Each stored secret fails to decrypt when it is used. The log says
[admin] secret "integrations.<name>" cannot be decrypted: was the secretKey changed?and PIT treats the secret as missing: competitors show "No model key", payments stop, Status shows blockers. Integrations can still show the field as set, because the encrypted value is still stored. - Authenticator codes are refused (
[admin] two-factor secret of <email> cannot be decrypted). Recovery codes still work, because they are hashes, not encrypted. - Passwords, settings, the audit log,
pit.dbandarena.config.jsonare not affected.
Recovery
- Old key found?Put it back in the environment and restart. Everything decrypts again.
- Old key goneKeep the new key, then enter every secret again in Integrations (or set them as environment variables). Saving overwrites the unreadable value.
- Two-factorEach user signs in with one recovery code, turns two-factor off under Security & alerts (password plus another recovery code) and sets it up again. An owner can also remove a member under Team and add them again, which clears their two-factor.
- Sole owner with no recovery codes leftThere is no back door. Stop the server, move
admin.sqlite(and its-wal,-shmfiles) aside, start again with a newADMIN_SETUP_TOKEN(or read the new setup code printed in the log). Create the owner, restore settings with Export / Import from an earlier export, and re-enter the secrets.pit.dband the arena file are untouched.
Changing the key on purpose
There is no automatic re-encryption. Export the settings (Export / Import; secrets are never exported), ask every user with two-factor to turn it off, set the new key, restart, enter the secrets again, and let users set up two-factor again.
Backups
deploy/backup.mjscopiespit.dbandadmin.sqlitesafely while the server runs (how). It does not copyarena.config.jsonor.env; copy those yourself. Fly.io volume snapshots cover the whole/datavolume.- A backup of
admin.sqliteis useless without the key it was written with. KeepADMIN_SECRET_KEYandPIT_SECRETin a password manager, not in the same folder as the backups. - Export the console settings after big changes, and keep each user's recovery codes somewhere safe.
04What you must protect
- Treat
OPERATOR_TOKEN,ADMIN_SECRET_KEY,ADMIN_SETUP_TOKEN,PIT_SECRET,TRUSTED_PROXY_SECRETand every provider key as passwords. - Never commit
.env,data/or a settings file exported from the console. - Keep a safe copy of
ADMIN_SECRET_KEY: a backup ofadmin.sqliteis unreadable without it (details). - Keep the runner browser clean: it signs real orders while it runs.