fix(playtest): require the secrets, and stop reflecting every CORS origin #244

Merged
sionzee merged 1 commit from feat/playtest-env-hardening into main 2026-08-11 01:11:29 +02:00
Owner

Follow-up to #242 and the provider-credentials change, from deploying the portal to production.

CORS

CORSPlugin's default is origin: (origin) => origin — it reflects whatever origin asked. The portal is a SPA on a different origin, so the oRPC handler is reachable from browsers, and playtest.* reveals Steam keys and NDA name/address. Now an allowlist: CORS_ALLOWED_ORIGINS, defaulting to PLAYTEST_PORTAL_URL. Server-side callers (bot, allcom.zone SSR) send no Origin and are unaffected.

Secrets have no defaults any more

JWT_SECRET fell back to "allcom-zone-secret-key-change-in-production" in six places across four contracts. That literal is in this repository, so a deployment that forgot the variable would verify tokens anyone could mint — and since playtesters reuse the Evobug user JWT, that now reaches another user's NDA record. ADMIN_SYNC_KEY had the same shape (|| "change-me-in-production").

Both are required and resolved through the new src/config.ts. validateEnvironment() runs before anything connects or listens and reports every problem at once, including a wrong-length PLAYTEST_ENCRYPTION_KEY — which was read lazily, so a container missing it started, reported healthy, served pages, and only failed later at admin login.

portalEnv and empty strings

?? only falls back on undefined. docker-compose defines every variable it lists, so - PLAYTEST_MICKLE_CLIENT_ID=${PLAYTEST_MICKLE_CLIENT_ID} with nothing in .env arrives as "" and would win over a working shared credential — the provider 503s and the fallback never fires. Switched to ||, with a test.

Key rotation

PLAYTEST_ENCRYPTION_KEY_PREVIOUS accepts retired keys for decryption only, so rotation becomes deploy-then-backfill instead of an outage. It deliberately does not cover the keyed fingerprint columns (Steam key dedupe, recovery codes), which are equality indexes and must be recomputed by the re-encryption job — documented in the code and .env.example.

Verification

706 pass / 0 fail (28 files), tsgo clean, oxlint 0 errors and no new warnings on the touched files.

Deployment note

The API will refuse to start without DATABASE_URL, JWT_SECRET, PLAYTEST_ENCRYPTION_KEY and ADMIN_SYNC_KEY. All four are already set on the production host, and ADMIN_SYNC_KEY is shared with the bot (both containers restarted with a matching value), so this is safe to deploy as-is.

🤖 Generated with Claude Code

Follow-up to #242 and the provider-credentials change, from deploying the portal to production. ### CORS `CORSPlugin`'s default is `origin: (origin) => origin` — it reflects whatever origin asked. The portal is a SPA on a different origin, so the oRPC handler is reachable from browsers, and `playtest.*` reveals Steam keys and NDA name/address. Now an allowlist: `CORS_ALLOWED_ORIGINS`, defaulting to `PLAYTEST_PORTAL_URL`. Server-side callers (bot, allcom.zone SSR) send no `Origin` and are unaffected. ### Secrets have no defaults any more `JWT_SECRET` fell back to `"allcom-zone-secret-key-change-in-production"` in six places across four contracts. That literal is in this repository, so a deployment that forgot the variable would verify tokens anyone could mint — and since playtesters reuse the Evobug user JWT, that now reaches another user's NDA record. `ADMIN_SYNC_KEY` had the same shape (`|| "change-me-in-production"`). Both are required and resolved through the new `src/config.ts`. `validateEnvironment()` runs before anything connects or listens and reports every problem at once, including a wrong-length `PLAYTEST_ENCRYPTION_KEY` — which was read lazily, so a container missing it started, reported healthy, served pages, and only failed later at admin login. ### `portalEnv` and empty strings `??` only falls back on `undefined`. docker-compose defines every variable it lists, so `- PLAYTEST_MICKLE_CLIENT_ID=${PLAYTEST_MICKLE_CLIENT_ID}` with nothing in `.env` arrives as `""` and would win over a working shared credential — the provider 503s and the fallback never fires. Switched to `||`, with a test. ### Key rotation `PLAYTEST_ENCRYPTION_KEY_PREVIOUS` accepts retired keys for **decryption only**, so rotation becomes deploy-then-backfill instead of an outage. It deliberately does not cover the keyed `fingerprint` columns (Steam key dedupe, recovery codes), which are equality indexes and must be recomputed by the re-encryption job — documented in the code and `.env.example`. ### Verification 706 pass / 0 fail (28 files), `tsgo` clean, oxlint 0 errors and no new warnings on the touched files. ### Deployment note The API will refuse to start without `DATABASE_URL`, `JWT_SECRET`, `PLAYTEST_ENCRYPTION_KEY` and `ADMIN_SYNC_KEY`. All four are already set on the production host, and `ADMIN_SYNC_KEY` is shared with the bot (both containers restarted with a matching value), so this is safe to deploy as-is. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(playtest): require the secrets, and stop reflecting every CORS origin
Some checks failed
OpenCodeReview / review (deepseek) (pull_request) Failing after 10s
OpenCodeReview / review (luna) (pull_request) Has been cancelled
Tests / test (pull_request) Successful in 58s
bf98c31c97
Four hardening changes the portal work surfaced.

CORS: CORSPlugin's default is `origin: (origin) => origin`, which reflects
whatever asked. The portal is a SPA on another origin, so the handler is
browser-reachable, and playtest.* reveals Steam keys and NDA identity data -
any page on the web could drive it in a logged-in visitor's browser. Allowlist
via CORS_ALLOWED_ORIGINS, defaulting to PLAYTEST_PORTAL_URL. Server-side
callers send no Origin and are unaffected.

Secrets: JWT_SECRET defaulted to a literal that ships in this repository, in
six places across four contracts, so a deployment that forgot it verified
tokens anyone could mint - now that portal identity rides the same token, that
reaches another user's NDA record. ADMIN_SYNC_KEY had the same shape. Both are
required, resolved through src/config.ts, and validated at boot alongside
PLAYTEST_ENCRYPTION_KEY - which was read lazily, so a container missing it
started, reported healthy, and failed later at admin login.

portalEnv used `??`, which only falls back on undefined. docker-compose defines
every variable it lists, so `- PLAYTEST_MICKLE_CLIENT_ID=${...}` with nothing
in .env arrives as "" and would have shadowed a working shared credential,
503-ing the provider with the fallback never firing.

PLAYTEST_ENCRYPTION_KEY_PREVIOUS accepts retired keys for decryption, turning
rotation into deploy-then-backfill instead of an outage. It does not cover the
keyed fingerprint columns, which the re-encryption job has to recompute.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
sionzee deleted branch feat/playtest-env-hardening 2026-08-11 01:11:29 +02:00
Sign in to join this conversation.
No description provided.