From 11beb3c12d131b78e839af11f180665a555e441f Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Wed, 5 Aug 2026 23:36:50 -0400 Subject: [PATCH 01/14] Add the v1.8 SuperTokens implementation plan Seven tasks derived from spec section 5. No v1.8 plan existed - only the design - so this is the missing half. Grounded against the current code rather than the spec's summary of it: confirmed req.user.sub is 25 of the 27 req.user reads (username and avatarUrl one each), which is what makes the "zero route handler changes" seam real; confirmed identities.supertokens_user_id already ships unused from v1.7; and confirmed supertokens-node@24.0.3 declares no engines constraint, so it is importable on the Node 20 production image. Two v1.7 lessons are carried in as global constraints. The dependency-on-a- newer-Node trap that left CI silently red for four commits is now an explicit check in Task 2 Step 1. The ${VAR:-default} vs ${VAR-default} distinction is spelled out in Task 1 Step 3, because the colon form treats an explicitly empty value as unset and would defeat the documented rollback. Also corrected a claim the spec inherits: it warns that `postgres://` is rejected, which v1.7 disproved for rackstack's own DATABASE_URL (pg accepts both schemes, verified directly). The SuperTokens core is a different component and does reject it, so the plan scopes that warning to the SuperTokens connection URI instead of restating the debunked general claim. Records the sequencing precondition honestly: the spec gates v1.8 on v1.7 being confirmed in production, which has not happened. That blocks running shadow mode against real identities and blocks cutover - but not building, since AUTH_MODE defaults to passport and every task is inert until an operator changes it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS --- .../plans/2026-08-06-v1.8-supertokens.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-v1.8-supertokens.md diff --git a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md new file mode 100644 index 0000000..3785371 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md @@ -0,0 +1,277 @@ +# v1.8 SuperTokens Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Introduce SuperTokens as an alternative authentication provider behind a strangler switch, without changing a single route handler and without any existing player losing access to their save. + +**Architecture:** A new `AUTH_MODE` env var selects between the legacy passport+JWT stack (`passport`, the default), both stacks running side by side (`dual`), and SuperTokens alone (`supertokens`). The seam is `req.user.sub` — verified to be the only identity field 25 of the 27 `req.user` reads depend on. SuperTokens' external user-id mapping makes `session.getUserId()` return the existing `provider:providerId` string, so `users.id`, every foreign key, and `SUPER_ADMIN_IDS` are untouched. + +**Tech Stack:** Node 20 (ESM), Express 4, `supertokens-node` 24, SuperTokens core `supertokens-postgresql`, Postgres 16, vitest 3, Testcontainers. + +**Spec:** `docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md` §5 + +**Predecessor:** v1.7 (`f718dab`, tag `v1.7.0`). The `identities` table and its unused `supertokens_user_id TEXT UNIQUE` column already ship. + +## Sequencing precondition + +The spec (§5) says v1.8 starts only after v1.7 is confirmed running in production. At the time of writing, v1.7 is tagged and published to GHCR but **has not been cut over on the owner's Unraid box**, and the production export named in §5.5 has not been supplied. + +This does not block building v1.8, because `AUTH_MODE` defaults to `passport` and every task below is inert until the operator changes it. It *does* block two specific things, which are therefore explicitly out of scope for the implementation and called out in Task 7: + +1. Running shadow mode against real production identities. +2. Cutting over to `AUTH_MODE=dual`. + +Do not let any task claim "verified" for either of those. + +## Global Constraints + +- **Node 20, ESM only.** The Dockerfile ships `node:20-bookworm-slim` and CI matches it deliberately. A dependency that requires Node 22 cannot be imported on any code path CI or production reaches — this is exactly how v1.7's CI was silently red for four commits (`undici@8` via Testcontainers). `supertokens-node@24` declares no `engines` constraint; verify that still holds at install time. +- **`AUTH_MODE=passport` is the default and must be byte-for-byte the behaviour that ships today.** In this mode SuperTokens is not initialised, its middleware is not mounted, and `supertokens-node` is **not imported** — the import must be dynamic and behind the mode check. The full existing test suite must pass with no changes in this mode. +- **`users.id` never changes.** It is `provider:providerId` and it is the primary key every foreign key, `SUPER_ADMIN_IDS` entry, and save row points at. SuperTokens' internal user id is mapped *to* it, never substituted *for* it. +- **The user-id mapping must be created before the session is issued.** This is the single highest-risk line in the release (spec §5.3). A session issued first carries SuperTokens' internal id, and every route resolves the wrong (or no) save. Asserted by test, not by reading docs. +- **`req.user` keeps its exact shape** — `{ sub, username, avatarUrl }` — regardless of which stack authenticated the request. No route handler changes in this release. +- **Legacy JWT cookies stay valid for their full 90-day expiry** through every mode transition, in both directions. Rollback is `AUTH_MODE=passport` + restart, with no forced logout. +- **Nothing is removed from the OAuth apps.** The GitHub callback is *widened*, the Discord redirect is *added*. Passport keeps working throughout. +- **Both backends still tested.** v1.8 touches `identities`; `npm run test:all` must stay green on SQLite and Postgres. +- **Documentation is updated in the same task that changes the behaviour**, not batched at the end. Owner's explicit instruction. Every task below names the doc it must leave correct. + +## File Structure + +**Created:** +- `server/authMode.js` — parses and validates `AUTH_MODE`; the single authority +- `server/supertokens/init.js` — `supertokens.init()` with ThirdParty + Session recipes +- `server/supertokens/providers.js` — GitHub/Discord provider config from existing env vars +- `server/supertokens/mapping.js` — the `signInUp` override: identity lookup → user id → `createUserIdMapping` → session +- `server/supertokens/shadow.js` — shadow-mode comparison and report +- `tests/authMode.test.js` +- `tests/supertokens.mapping.test.js` — including the ordering assertion +- `tests/supertokens.middleware.test.js` — the auth chain across all three modes +- `tests/supertokens.shadow.test.js` +- `docs/supertokens-rollout-runbook.md` — operator runbook: OAuth URL changes, shadow gate, cutover, rollback + +**Modified:** +- `server/auth.js` — `requireAuth` becomes a chain; passport config unchanged +- `server/app.js` — conditional SuperTokens middleware + error handler +- `server/routes/api.js` — auth routes gated by mode; **no handler body changes** +- `server/db/index.js`, `server/db/driver.pg.js`, `server/db/driver.sqlite.js`, `server/db/interface.md` — two new interface functions (Task 3) +- `package.json`, `docker-compose.yml`, `unraid-template.xml`, `.env.example`, `README.md`, `CHANGELOG.md` + +**Deleted:** nothing. + +--- + +### Task 1: `AUTH_MODE` plumbing and the containment guarantee + +Establish the switch and prove it contains everything, *before* any SuperTokens code exists. This ordering is deliberate: it means every later task is added behind a guard that is already tested. + +**Files:** +- Create: `server/authMode.js`, `tests/authMode.test.js` +- Modify: `.env.example`, `README.md`, `unraid-template.xml`, `docker-compose.yml` + +**Interfaces:** +- Produces: `AUTH_MODES` (frozen `['passport','dual','supertokens']`), `resolveAuthMode(env)`, `isSuperTokensEnabled(mode)`, `isPassportEnabled(mode)`. + +- [ ] **Step 1: Write `resolveAuthMode`** + +Unset or empty → `'passport'`. An unrecognised value must **throw at boot**, not silently fall back — a typo'd `AUTH_MODE=supertoken` that quietly serves the legacy stack is the kind of thing nobody notices until the rollout is presumed complete. The error names the received value and lists the three valid ones. + +- [ ] **Step 2: Test it** + +Cover: default when unset, default when empty string, each of the three valid values, case handling (decide and document: recommend accepting exact lowercase only, and erroring on `Passport` with a message that says so), and the throw on garbage. + +- [ ] **Step 3: Document the variable everywhere it can be set** + +`.env.example` gets `AUTH_MODE=` (commented, defaulting to passport) with the three-value table and the "rollback is setting this back to passport" note. `unraid-template.xml` gets a `Config` entry — `Display="advanced"`, `Required="false"`, default empty. `README.md` gets the three-value table. `docker-compose.yml` gets `AUTH_MODE: ${AUTH_MODE-passport}` — note the **non-colon** form, per the v1.7 finding that `${VAR:-default}` treats an explicitly empty value as unset and would defeat the documented rollback. + +- [ ] **Step 4: Verify containment** + +`npm run test:all` with no `AUTH_MODE` set must pass unchanged. Add an assertion that `resolveAuthMode({})` is `'passport'` so the default can never drift. + +--- + +### Task 2: SuperTokens initialisation, behind the guard + +**Files:** +- Create: `server/supertokens/init.js`, `server/supertokens/providers.js` +- Modify: `package.json`, `server/app.js`, `docker-compose.yml`, `.env.example`, `unraid-template.xml` + +**Interfaces:** +- Consumes: Task 1's `resolveAuthMode`. +- Produces: `initSuperTokens({ env })` (idempotent; returns `false` when mode is `passport`), `mountSuperTokens(app, mode)`. + +- [ ] **Step 1: Add the dependency** + +`npm install supertokens-node@^24`. Then confirm the constraint that matters: `npm ls` on **Node 20** must not warn `EBADENGINE` for it or anything it pulls in. If it does, that transitive package cannot be on a CI or production code path — treat it exactly like the `undici` incident and report it rather than bumping Node. + +- [ ] **Step 2: `providers.js`** + +Map the existing `GITHUB_CLIENT_ID`/`SECRET` and `DISCORD_CLIENT_ID`/`SECRET` into SuperTokens' ThirdParty provider config, using `thirdPartyId` values `'github'` and `'discord'` — these must match the `provider` values already stored in `identities`, because Task 3 keys off them. A provider with no credentials is omitted, mirroring `configurePassport`'s behaviour rather than inventing a new one. + +- [ ] **Step 3: `init.js`** + +`supertokens.init()` with `Session` and `ThirdParty` recipes only (spec §7 puts every other recipe out of scope). `connectionURI` from `SUPERTOKENS_CONNECTION_URI`; `apiDomain`/`websiteDomain` from existing config. The `supertokens-node` import must be **dynamic**, inside the function, after the mode check — so `AUTH_MODE=passport` never loads the SDK. Make the function idempotent: a second call is a no-op, not a double-init throw. + +- [ ] **Step 4: Mount conditionally in `app.js`** + +SuperTokens' `middleware()` before the API router and its `errorHandler()` after — both only when `isSuperTokensEnabled(mode)`. In `passport` mode `app.js`'s middleware stack must be identical to today's; assert this in a test rather than by eye. + +- [ ] **Step 5: Compose + template + env** + +`docker-compose.yml` gains a `supertokens` service (`registry.supertokens.io/supertokens/supertokens-postgresql`, port 3567) with `POSTGRESQL_CONNECTION_URI` pointing at its **own database** on the v1.7 Postgres instance — not the rackstack database. Document both footguns from spec §5.1 in the compose comments and `.env.example`: the scheme must be `postgresql://`, and the host may not be `localhost` from inside a container. Add a healthcheck and make rackstack `depends_on` it only when SuperTokens is in play. + +> **Note the v1.7 correction:** the `postgres://` scheme is fine for *rackstack's* own `DATABASE_URL` (`pg` accepts both, verified). The SuperTokens **core** is the component that genuinely rejects it. Do not restate the debunked claim; scope the warning to the SuperTokens connection URI. + +- [ ] **Step 6: Docs** + +`README.md` gains a SuperTokens section: what the container is, that it needs its own database, and that none of it is reached in the default mode. + +--- + +### Task 3: Identity mapping — the critical mechanism + +The highest-risk task in the release. Read spec §5.3 in full before starting. + +**Files:** +- Create: `server/supertokens/mapping.js`, `tests/supertokens.mapping.test.js` +- Modify: `server/db/index.js`, `server/db/driver.pg.js`, `server/db/driver.sqlite.js`, `server/db/interface.md` + +**Interfaces:** +- Consumes: `upsertUser`, `listIdentities` from the v1.7 db interface. +- Produces: two new interface functions, implemented on **both** drivers and added to `interface.md` and `tests/db.interface.test.js`'s `INTERFACE` array: + - `getIdentity(provider, providerId)` → the identity row or `undefined` + - `setSupertokensUserId(provider, providerId, supertokensUserId)` → void + +- [ ] **Step 1: Add the two db interface functions** + +Both drivers, same semantics, `undefined` for a miss (matching the v1.7 constraint). `setSupertokensUserId` must tolerate being called twice with the same value (re-login) without violating the `UNIQUE` constraint — decide between an idempotent guard and an upsert, and test the re-login path explicitly. + +- [ ] **Step 2: Write the `signInUp` recipe-function override** + +Order is the whole point: + +1. `thirdPartyId` + `thirdPartyUserId` from the input. +2. `getIdentity(thirdPartyId, thirdPartyUserId)`. +3. Existing identity → its `user_id`. No identity → `upsertUser(...)` to create user + identity atomically (v1.7 already made that one transaction). +4. `createUserIdMapping({ supertokensUserId, externalUserId: })`. +5. `setSupertokensUserId(...)` to record the linkage on our side. +6. **Only then** return, letting the session be issued. + +Put this in the **recipe-function** override, not the API override — spec §5.3 is explicit, because the API override runs after the session already exists. + +- [ ] **Step 3: Test the ordering directly** + +This is the test the release hinges on, and it must be impossible to pass by accident. Do not assert "a mapping exists at the end" — that passes even if the session was issued first. Instrument the call order: record the sequence of `createUserIdMapping` and session-creation calls and assert mapping precedes session. A test that would still pass with the two lines swapped is not a test of this requirement. + +- [ ] **Step 4: Test the identity outcomes** + +- Existing player, existing identity → `session.getUserId()` is their **existing** `users.id`, and no new user row is created. +- Brand-new player → user + identity created, mapping points at the new `users.id`. +- Same player logging in twice → no duplicate identity, no `UNIQUE` violation on `supertokens_user_id`. +- A player who exists via passport but has never used SuperTokens → resolves to the same save. This is the whole point of the release; assert on the save contents, not just the id. + +- [ ] **Step 5: Update `interface.md`** + +Both new functions, with the `undefined`-on-miss contract and the re-login idempotence note. + +--- + +### Task 4: The authentication chain + +**Files:** +- Modify: `server/auth.js`, `server/routes/api.js` +- Create: `tests/supertokens.middleware.test.js` + +**Interfaces:** +- Consumes: Tasks 1–3. +- Produces: `requireAuth` resolving `req.user = { sub, username, avatarUrl }` from either stack. + +- [ ] **Step 1: Turn `requireAuth` into a chain** + +Try the SuperTokens session first (only when enabled), fall back to the legacy JWT cookie, then 401. Both paths populate the identical `req.user` shape. `requireRole` already re-derives roles from `req.user.sub` on every request and needs no change — confirm that by reading it, and say so. + +The SuperTokens attempt must not throw past the fallback: a malformed or expired SuperTokens session in `dual` mode has to fall through to the JWT check, not 500. This is the failure mode that would take down logins for users mid-rollout. + +- [ ] **Step 2: Gate the passport routes by mode** + +In `supertokens` mode the `/auth/discord` and `/auth/github` passport routes are not registered. `/auth/logout` must clear **both** a legacy cookie and a SuperTokens session, in every mode — a logout that only half-works is worse than one that fails loudly. + +- [ ] **Step 3: Test all three modes** + +For each of `passport`, `dual`, `supertokens`: an authenticated request reaches a protected route with the right `sub`; an unauthenticated one gets 401. In `dual`, specifically: a legacy JWT cookie issued *before* the switch still authenticates. That is the no-forced-logout guarantee, and it is the one users would notice. + +- [ ] **Step 4: Prove passport mode is unchanged** + +The full pre-existing suite, untouched, green in `passport` mode. + +--- + +### Task 5: Shadow-mode verification gate + +Spec §5.5. This exists because "SuperTokens' `thirdPartyUserId` equals passport's `profile.id`" is load-bearing and unverified, and the failure mode is a player silently landing on a brand-new empty save. + +**Files:** +- Create: `server/supertokens/shadow.js`, `tests/supertokens.shadow.test.js` +- Modify: `docs/supertokens-rollout-runbook.md` + +- [ ] **Step 1: Implement the comparison** + +Given a completed SuperTokens third-party login, compute `` `${thirdPartyId}:${thirdPartyUserId}` `` and compare against `identities`. Report match / mismatch / no-such-identity. **It must not alter the caller's session or write anything** — that is what makes it safe to run against production. + +- [ ] **Step 2: Make the result legible to an operator** + +A per-login log line plus a summary suitable for the cutover decision: total compared, matched, mismatched, with the mismatching pairs named. The gate is 100%; anything less must be visibly not-100%, not buried. + +- [ ] **Step 3: Test both outcomes** + +Matching and deliberately mismatching id shapes, plus the "identity does not exist yet" case. Assert the no-write property directly — snapshot the identities table before and after and compare. + +- [ ] **Step 4: Document the gate** + +The runbook states plainly: cutover to `dual` is gated on 100%, and the check is run against the owner's production export. Record that this has **not** been run yet. + +--- + +### Task 6: OAuth callback URLs + +The change that would otherwise break every GitHub login (spec §5.4). + +**Files:** +- Modify: `docs/supertokens-rollout-runbook.md`, `README.md`, `.env.example` + +- [ ] **Step 1: Write the GitHub instruction precisely** + +GitHub's rule: the redirect URL's path must reference a **subdirectory** of the registered callback URL. SuperTokens uses `/auth/callback/`; RackStack uses `/auth//callback`. `/auth/callback/github` is *not* a subdirectory of `/auth/github/callback`, so left alone every SuperTokens GitHub login fails with `redirect_uri` mismatch. + +Fix: widen the registered callback to `https:///auth`. Both paths then qualify. Nothing is removed, so passport keeps working — state that explicitly, because "change your OAuth app" reads as dangerous and the operator needs to know it is additive and reversible. + +- [ ] **Step 2: Discord** + +Discord permits multiple redirect URIs; the SuperTokens one is added alongside the existing one. Nothing removed. + +- [ ] **Step 3: Order the runbook correctly** + +The OAuth URL change must happen **before** `AUTH_MODE=dual`, and it is safe to do days earlier. Say so — sequencing is the whole risk here. + +--- + +### Task 7: Deployment config, docs, release + +**Files:** +- Modify: `docker-compose.yml`, `unraid-template.xml`, `.env.example`, `README.md`, `CHANGELOG.md`, `Dockerfile` (version label), `package.json` (version) +- Create/finish: `docs/supertokens-rollout-runbook.md` + +- [ ] **Step 1: Finish the runbook** + +Parts mirroring v1.7's: prerequisites (v1.7 in production, backup taken), OAuth widening, stand up the SuperTokens container, run shadow mode, read the gate, cut over to `dual`, verify, then optionally `supertokens`. Rollback is its own part: `AUTH_MODE=passport` + restart, JWT cookies still valid, nothing lost. + +- [ ] **Step 2: State what has not been verified** + +Explicitly, in the runbook and the PR: shadow mode has not been run against production identities, and no cutover has happened. Do not let the runbook imply otherwise — v1.7's runbook had to be corrected for exactly this. + +- [ ] **Step 3: Version + changelog** + +Bump `package.json` to 1.8.0 and the Dockerfile's `org.opencontainers.image.version` label to match. Per the v1.7 correction, `client/package.json` is deliberately **not** bumped — `client/vite.config.js` reads the root `package.json` as the single version authority. Tag only after merge to main. + +- [ ] **Step 4: Full verification** + +`npm run test:all` green on both backends, all six e2e smoke suites green, and a real boot in each of the three `AUTH_MODE` values. From 21de00b765eebf30fa99b9348b7d52d9821ab91b Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Wed, 5 Aug 2026 23:40:01 -0400 Subject: [PATCH 02/14] v1.8 Task 1: AUTH_MODE switch and its containment guarantee Establishes the strangler switch before any SuperTokens code exists, so every later task lands behind a guard that is already tested rather than one added around it afterwards. server/authMode.js resolves AUTH_MODE to one of passport (default), dual or supertokens. Two decisions worth recording: Blank means passport, because blanking the field in the Unraid UI is the documented rollback and must not be an error. But an unrecognised value THROWS at boot rather than falling back - a typo'd AUTH_MODE=supertoken that quietly served the legacy stack would be indistinguishable from a completed rollout, and would surface weeks later from the wrong symptom. Matching is case-sensitive for the same reason, with the error naming the likely intent so the fix is in the message. Documented in all four places an operator can set it: .env.example, README.md, unraid-template.xml (Display="advanced", so it stays out of the way of people who will never touch it), and docker-compose.yml. The compose entry uses ${AUTH_MODE-passport}, deliberately not the colon form - per the v1.7 finding, ${VAR:-default} treats an explicitly empty value as unset and would substitute the default right back, defeating the documented rollback. Also adds docs/supertokens-rollout-runbook.md rather than leaving the README pointing at a file that does not exist. It is honest about state: a status table of what is and is not built, and Parts B-D marked pending. Part A (the OAuth redirect widening) is written in full now because it is pure documentation, knowable today, and must be applied days BEFORE any cutover - GitHub requires the redirect path to be a subdirectory of the registered callback, and /auth/callback/github is not a subdirectory of /auth/github/callback, so every SuperTokens GitHub login would otherwise fail with a redirect_uri mismatch. The instruction widens the registration to /auth, which is additive and reversible; nothing is removed and passport keeps working throughout. The runbook also corrects a claim inherited from the design: `postgres://` is rejected by the SuperTokens core specifically, not by rackstack's own DATABASE_URL, which v1.7 proved accepts either scheme. Containment verified: npm run test:all green on both backends with no AUTH_MODE set - sqlite 507 passed/26 skipped, postgres 530 passed/3 skipped (+15 each, the new authMode suite). 15 tests cover the default, the empty and whitespace cases, all three valid values, the throw, the casing message, and set-level properties: no mode leaves both stacks disabled, and exactly one mode runs each stack alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS --- .env.example | 27 +++++ README.md | 31 ++++++ docker-compose.yml | 6 + docs/supertokens-rollout-runbook.md | 166 ++++++++++++++++++++++++++++ server/authMode.js | 71 ++++++++++++ tests/authMode.test.js | 105 ++++++++++++++++++ unraid-template.xml | 4 + 7 files changed, 410 insertions(+) create mode 100644 docs/supertokens-rollout-runbook.md create mode 100644 server/authMode.js create mode 100644 tests/authMode.test.js diff --git a/.env.example b/.env.example index 1079447..55127ae 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,33 @@ DATABASE_URL= # Leave blank to disable admin access entirely. SUPER_ADMIN_IDS= +# --- Authentication stack (v1.8) --- +# Which login stack runs. Leave blank unless you are actively rolling out +# SuperTokens - blank means the stack RackStack has always used. +# +# passport (default) Exactly as before. SuperTokens is not initialised, +# and its SDK is not even loaded. Upgrading to v1.8 without +# setting this changes nothing about how anyone logs in. +# dual Both login paths work; a session from either is accepted. +# This is where the rollout happens. Existing login cookies +# keep working for their full 90-day life. +# supertokens SuperTokens only; the old OAuth routes are switched off. +# +# Rolling back is setting this back to passport (or blanking it) and +# restarting. Existing login cookies stay valid through every transition in +# both directions, so nobody is logged out by changing this. +# +# An unrecognised value stops the server on purpose rather than quietly +# falling back, so a typo can't look like a completed rollout. +# AUTH_MODE= + +# Where the SuperTokens core container is reachable. Only read when AUTH_MODE +# is dual or supertokens. Two things the SuperTokens core is fussy about: +# the scheme must be postgresql:// (it rejects postgres://, unlike DATABASE_URL +# above, which accepts either), and it needs its OWN database - point it at a +# separate database on the same Postgres server, never at the rackstack one. +# SUPERTOKENS_CONNECTION_URI=http://supertokens:3567 + # --- Discord OAuth --- # Create an app at https://discord.com/developers/applications # -> OAuth2 -> add a redirect matching DISCORD_CALLBACK_URL below diff --git a/README.md b/README.md index 4a8218b..7812a6a 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,37 @@ variable actually lives for your deployment: | Docker Compose | `.env` — `docker-compose.yml` reads it via `${DATABASE_URL:-...}` | | Local `npm start` | `.env` | +### Authentication stack (`AUTH_MODE`) + +RackStack is gaining SuperTokens as an alternative login stack, rolled out +behind a switch rather than swapped in one step. **If you do nothing, nothing +changes** — the default is the passport + JWT stack that has always shipped, +and the SuperTokens SDK is not even loaded. + +| `AUTH_MODE` | Behaviour | +|---|---| +| *(blank)* or `passport` | Default. Exactly as before; SuperTokens is not initialised. | +| `dual` | Both login paths live, sessions from either accepted. Where the rollout happens. | +| `supertokens` | SuperTokens only; the legacy OAuth routes are not registered. | + +Two properties worth knowing before you touch it: + +- **Changing this never logs anyone out.** Existing login cookies stay valid + for their full 90 days through every transition, in both directions, so + rollback is just setting it back to `passport` and restarting. +- **A typo stops the container** instead of quietly falling back to the + default. `AUTH_MODE=supertoken` would otherwise serve the legacy stack while + looking like a finished rollout — the kind of thing you'd discover weeks + later, from the wrong symptom. + +`SUPERTOKENS_CONNECTION_URI` points at the SuperTokens core container and is +read only in `dual`/`supertokens`. That core needs its **own** database on +your Postgres server, separate from the rackstack one. + +Full walkthrough — including the OAuth redirect-URL change that has to happen +*before* `dual`, and the verification gate before cutover — is in +[`docs/supertokens-rollout-runbook.md`](./docs/supertokens-rollout-runbook.md). + **Cutting a release:** bump `version` in `package.json` (the single release- version authority - `client/vite.config.js` reads it for `__APP_VERSION__`, and `client/package.json`'s own version is deliberately not kept in sync), diff --git a/docker-compose.yml b/docker-compose.yml index 331916f..8fc6eb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,5 +42,11 @@ services: # tell you to do - would silently keep pointing at Postgres. Without the # colon, only a genuinely unset variable takes the default. DATABASE_URL: ${DATABASE_URL-postgresql://rackstack:rackstack@postgres:5432/rackstack} + # v1.8 auth stack selector. Defaults to the legacy passport + JWT stack, + # so `docker compose up` behaves exactly as it did before v1.8. Same + # non-colon ${VAR-default} form and the same reason: `AUTH_MODE=` in + # .env must mean "the default", not "unset, so substitute the default" + # - which is what the colon form would do, defeating the rollback. + AUTH_MODE: ${AUTH_MODE-passport} env_file: - .env diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md new file mode 100644 index 0000000..5f897b4 --- /dev/null +++ b/docs/supertokens-rollout-runbook.md @@ -0,0 +1,166 @@ +# SuperTokens Rollout Runbook (v1.8) + +**Status: IN PROGRESS — not ready to run.** The `AUTH_MODE` switch exists and +defaults to the legacy stack, so v1.8 is safe to *deploy*. It is not yet safe +to *roll out*: the SuperTokens integration behind the switch is still being +built. Parts B onward are placeholders until the tasks that back them land. + +Do not set `AUTH_MODE` to anything but blank or `passport` yet. + +--- + +## 0. What exists right now + +| Piece | Task | State | +|---|---|---| +| `AUTH_MODE` switch + validation | 1 | ✅ built | +| SuperTokens init + provider config | 2 | ⬜ not started | +| Identity mapping (`signInUp` override) | 3 | ⬜ not started | +| Auth middleware chain | 4 | ⬜ not started | +| Shadow-mode verification | 5 | ⬜ not started | +| OAuth callback URL changes | 6 | 📄 documented below, not yet needed | +| Deployment config + release | 7 | ⬜ not started | + +Plan: [`superpowers/plans/2026-08-06-v1.8-supertokens.md`](./superpowers/plans/2026-08-06-v1.8-supertokens.md) +Design: [`superpowers/specs/2026-08-01-postgres-supertokens-design.md`](./superpowers/specs/2026-08-01-postgres-supertokens-design.md) §5 + +--- + +## Prerequisites + +1. **v1.7 running in production**, on Postgres, confirmed working. The design + gates v1.8 on this and it has not happened yet — the Unraid cutover to + Postgres is still outstanding. See + [`postgres-migration-runbook.md`](./postgres-migration-runbook.md). +2. **A current backup**, taken the same way as for the Postgres migration. + This release does not move save data, but it does change how players are + identified, and that is not a thing to do without a way back. +3. **The OAuth redirect change below applied** — and it must be applied + *before* `AUTH_MODE=dual`, not at the same time. + +--- + +## Part A — OAuth redirect URLs (do this first, days early if you like) + +This is the change that would otherwise break every GitHub login the moment +SuperTokens is enabled. It is **additive and reversible**: nothing is removed, +and passport keeps working exactly as it does today, before and after. + +### A1. Why it is needed + +SuperTokens uses callback paths shaped `/auth/callback/`. RackStack +uses `/auth//callback`. GitHub's rule is that a redirect URL's path +must reference a **subdirectory** of the registered callback URL — and +`/auth/callback/github` is *not* a subdirectory of `/auth/github/callback`. + +Left alone, every SuperTokens GitHub login fails with a `redirect_uri` +mismatch, while passport logins carry on working, which makes it look like +SuperTokens is broken rather than like the OAuth app is misconfigured. + +### A2. GitHub — widen, do not replace + +In the GitHub OAuth app (Settings → Developer settings → OAuth Apps), change +the **Authorization callback URL** from: + +``` +https://your-domain.example.com/auth/github/callback +``` + +to the parent path: + +``` +https://your-domain.example.com/auth +``` + +Both `/auth/github/callback` (passport) and `/auth/callback/github` +(SuperTokens) are subdirectories of `/auth`, so both work simultaneously. + +Leave `GITHUB_CALLBACK_URL` in your environment pointing at the existing +`/auth/github/callback` — that variable tells passport where to send people, +and passport's path has not changed. + +### A3. Discord — add, do not replace + +Discord permits multiple redirect URIs. In the Discord application's OAuth2 +settings, **add**: + +``` +https://your-domain.example.com/auth/callback/discord +``` + +alongside the existing `https://your-domain.example.com/auth/discord/callback`. +Keep both. + +### A4. Verify before moving on + +Log in with Discord and with GitHub. Both must still work, because at this +point nothing about RackStack has changed — you have only widened what the +OAuth providers will accept. If a login broke here, revert the OAuth app +change and stop; do not proceed to Part B. + +--- + +## Part B — Stand up the SuperTokens core + +*Pending Task 2.* + +Will cover: the `registry.supertokens.io/supertokens/supertokens-postgresql` +container on port 3567, giving it its **own** database on the Postgres server +v1.7 stood up (never the rackstack database), and the two connection-string +footguns — the SuperTokens core requires the `postgresql://` scheme and +rejects `postgres://`, and the host may not be `localhost` from inside a +container. + +> Note: `postgres://` being rejected is specific to the **SuperTokens core**. +> RackStack's own `DATABASE_URL` accepts either scheme — an earlier draft of +> the design claimed otherwise and v1.7 disproved it. + +## Part C — Shadow-mode verification gate + +*Pending Task 5.* + +Will cover: running a SuperTokens login in shadow mode, which computes +`provider:thirdPartyUserId` and compares it against the existing `identities` +rows without touching the caller's session or writing anything. + +**Cutover is gated on a 100% match.** This exists because the assumption that +SuperTokens' `thirdPartyUserId` equals passport's `profile.id` is load-bearing +and unverified — and if it is wrong, the symptom is a player silently landing +on a brand-new empty save rather than an error anyone would notice. + +**This has not been run against production identities.** It cannot be until +the owner's export is available. + +## Part D — Cutover + +*Pending Tasks 3, 4 and 7. Gated on Part C reporting 100%.* + +## Part E — Rollback + +Rollback is complete and cheap at every stage: + +``` +Set AUTH_MODE=passport (or blank it) → restart +``` + +Existing login cookies are signed JWTs valid for a full 90 days, and nothing +in this release invalidates or rewrites them. A player mid-session does not +notice the round trip in either direction. Unlike the Postgres migration, +there is **no one-way door here** — no player data is rewritten by changing +`AUTH_MODE`, so a rollback days later costs nothing. + +**Do not change `JWT_SECRET` at any point during any of this.** Changing it +logs out every player, which looks alarmingly like the auth rollout having +gone wrong and will send you chasing the wrong problem. + +--- + +## Quick reference + +| Situation | Action | +|---|---| +| Deploying v1.8 | Nothing. Blank `AUTH_MODE` keeps the current login stack. | +| Container won't start, complains about `AUTH_MODE` | You typo'd it. Valid: `passport`, `dual`, `supertokens`, lowercase. | +| GitHub login fails with `redirect_uri` mismatch | Part A2 — widen the registered callback to `/auth`. | +| Anything looks wrong during rollout | `AUTH_MODE=passport`, restart. Nobody is logged out. | +| Everyone got logged out | Check `JWT_SECRET` is unchanged before anything else. | diff --git a/server/authMode.js b/server/authMode.js new file mode 100644 index 0000000..d6c9f97 --- /dev/null +++ b/server/authMode.js @@ -0,0 +1,71 @@ +// The strangler switch for the v1.8 SuperTokens rollout. +// +// v1.8 introduces a second authentication stack alongside the passport + JWT +// one that ships today. Rather than swap them in one step, AUTH_MODE runs +// them in sequence: +// +// passport - exactly today. SuperTokens is not initialised, its +// middleware is not mounted, and supertokens-node is never +// even imported. This is the default, so upgrading to v1.8 +// changes nothing about how anyone logs in. +// dual - both login paths live; a session from either is accepted. +// This is where the rollout actually happens, and where +// legacy 90-day JWT cookies keep working untouched. +// supertokens - the passport OAuth routes are no longer registered. +// +// Rollback in every direction is setting this back and restarting: legacy JWT +// cookies remain valid for their full 90-day expiry, so in-flight sessions +// survive the round trip and nobody is forced to log in again. +// +// See docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md section 5.2. + +export const AUTH_MODES = Object.freeze(['passport', 'dual', 'supertokens']); + +export const DEFAULT_AUTH_MODE = 'passport'; + +/** + * Resolves AUTH_MODE from an environment object. + * + * Unset, or set to nothing but whitespace, means the default - an operator + * blanking the field in the Unraid UI is choosing the legacy stack, which is + * the documented rollback, so it must land on `passport` rather than on an + * error. + * + * Anything else that isn't one of the three valid values THROWS, and does so + * at boot. Falling back to a default here would be worse than useless: a + * typo'd `AUTH_MODE=supertoken` would quietly serve the legacy stack while + * the operator believed the rollout had happened, and the first sign of + * trouble would be discovering weeks later that the migration never took + * effect. A container that refuses to start gets investigated immediately. + * + * Matching is exact and lowercase. `Passport` is rejected rather than + * silently accepted, because being lenient here means the value in the + * operator's config and the value in the logs can differ, and that is a + * miserable thing to debug during a rollout. + */ +export function resolveAuthMode(env = process.env) { + const raw = env.AUTH_MODE; + if (raw === undefined || raw === null || String(raw).trim() === '') { + return DEFAULT_AUTH_MODE; + } + + const value = String(raw).trim(); + if (AUTH_MODES.includes(value)) return value; + + const hint = AUTH_MODES.includes(value.toLowerCase()) + ? ` Did you mean '${value.toLowerCase()}'? Values are case-sensitive.` + : ''; + throw new Error( + `Invalid AUTH_MODE '${value}'. Valid values are: ${AUTH_MODES.join(', ')}.${hint}`, + ); +} + +/** True when this mode initialises SuperTokens and mounts its middleware. */ +export function isSuperTokensEnabled(mode) { + return mode === 'dual' || mode === 'supertokens'; +} + +/** True when this mode registers the passport OAuth routes. */ +export function isPassportEnabled(mode) { + return mode === 'passport' || mode === 'dual'; +} diff --git a/tests/authMode.test.js b/tests/authMode.test.js new file mode 100644 index 0000000..8a8c9c8 --- /dev/null +++ b/tests/authMode.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { + AUTH_MODES, DEFAULT_AUTH_MODE, resolveAuthMode, + isSuperTokensEnabled, isPassportEnabled, +} from '../server/authMode.js'; + +describe('resolveAuthMode', () => { + it('defaults to passport when AUTH_MODE is unset', () => { + expect(resolveAuthMode({})).toBe('passport'); + }); + + it('defaults to passport for an empty or whitespace value', () => { + // Blanking the field in the Unraid UI is the documented rollback, so it + // has to land on the legacy stack rather than throw. + expect(resolveAuthMode({ AUTH_MODE: '' })).toBe('passport'); + expect(resolveAuthMode({ AUTH_MODE: ' ' })).toBe('passport'); + }); + + it('accepts each of the three valid modes', () => { + expect(resolveAuthMode({ AUTH_MODE: 'passport' })).toBe('passport'); + expect(resolveAuthMode({ AUTH_MODE: 'dual' })).toBe('dual'); + expect(resolveAuthMode({ AUTH_MODE: 'supertokens' })).toBe('supertokens'); + }); + + it('tolerates surrounding whitespace', () => { + expect(resolveAuthMode({ AUTH_MODE: ' dual ' })).toBe('dual'); + }); + + it('throws on an unrecognised value rather than falling back', () => { + // The point of throwing: a typo that silently served the legacy stack + // would look exactly like a completed rollout until something went wrong + // weeks later. + expect(() => resolveAuthMode({ AUTH_MODE: 'supertoken' })).toThrow(/Invalid AUTH_MODE 'supertoken'/); + expect(() => resolveAuthMode({ AUTH_MODE: 'none' })).toThrow(/Invalid AUTH_MODE/); + }); + + it('names the valid values in the error, so the fix is in the message', () => { + expect(() => resolveAuthMode({ AUTH_MODE: 'nope' })) + .toThrow(/passport, dual, supertokens/); + }); + + it('rejects wrong casing but says what was meant', () => { + expect(() => resolveAuthMode({ AUTH_MODE: 'Passport' })) + .toThrow(/Did you mean 'passport'\?/); + expect(() => resolveAuthMode({ AUTH_MODE: 'SUPERTOKENS' })) + .toThrow(/case-sensitive/); + }); + + it('reads process.env when called with no argument', () => { + const saved = process.env.AUTH_MODE; + try { + process.env.AUTH_MODE = 'dual'; + expect(resolveAuthMode()).toBe('dual'); + } finally { + if (saved === undefined) delete process.env.AUTH_MODE; + else process.env.AUTH_MODE = saved; + } + }); +}); + +describe('mode predicates', () => { + it('enables SuperTokens for dual and supertokens only', () => { + expect(isSuperTokensEnabled('passport')).toBe(false); + expect(isSuperTokensEnabled('dual')).toBe(true); + expect(isSuperTokensEnabled('supertokens')).toBe(true); + }); + + it('enables passport routes for passport and dual only', () => { + expect(isPassportEnabled('passport')).toBe(true); + expect(isPassportEnabled('dual')).toBe(true); + expect(isPassportEnabled('supertokens')).toBe(false); + }); + + it('leaves no mode with both stacks disabled', () => { + // A mode that authenticated nobody would lock every player out, so this + // is a property of the set, not of any one value. + for (const mode of AUTH_MODES) { + expect(isSuperTokensEnabled(mode) || isPassportEnabled(mode)).toBe(true); + } + }); + + it('has exactly one mode where each stack runs alone, and one where both do', () => { + const both = AUTH_MODES.filter((m) => isSuperTokensEnabled(m) && isPassportEnabled(m)); + const stOnly = AUTH_MODES.filter((m) => isSuperTokensEnabled(m) && !isPassportEnabled(m)); + const passportOnly = AUTH_MODES.filter((m) => !isSuperTokensEnabled(m) && isPassportEnabled(m)); + expect(both).toEqual(['dual']); + expect(stOnly).toEqual(['supertokens']); + expect(passportOnly).toEqual(['passport']); + }); +}); + +describe('the default is load-bearing', () => { + it('is passport, so upgrading to v1.8 changes nobody\'s login', () => { + expect(DEFAULT_AUTH_MODE).toBe('passport'); + expect(resolveAuthMode({})).toBe(DEFAULT_AUTH_MODE); + }); + + it('does not initialise SuperTokens in the default mode', () => { + expect(isSuperTokensEnabled(resolveAuthMode({}))).toBe(false); + }); + + it('exposes AUTH_MODES frozen, so a caller cannot widen the valid set', () => { + expect(Object.isFrozen(AUTH_MODES)).toBe(true); + }); +}); diff --git a/unraid-template.xml b/unraid-template.xml index 0cf389e..7903001 100644 --- a/unraid-template.xml +++ b/unraid-template.xml @@ -32,6 +32,10 @@ + + + + From f66a894238facb4607f5f1df1f8753f8223775ec Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 11:11:07 -0400 Subject: [PATCH 03/14] v1.8 Task 2: SuperTokens init and provider config, behind the guard Adds supertokens-node@24 and everything needed to bring SuperTokens up, all of it dormant in the default passport mode. CONTAINMENT. Every supertokens-node import is dynamic and inside initSuperTokens(), after the mode check. This is a hard requirement, not style: the SDK pulls in nodemailer, twilio and libphonenumber-js to serve recipes this project never initialises, and v1.7 shipped four commits of silently-red CI because a top-level import dragged in a package that could not load on the runtime's Node version. A test asserts the absence of a static import at the source level, and was confirmed to fail when one is added. A second test asserts app.js mounts no extra middleware in passport mode. buildApp() is now async - not anticipated by the plan. SuperTokens' middleware() can only be mounted after init(), and init imports dynamically, so the mount point is necessarily async. All seven call sites updated. It also takes an { env } override so a test can build the app in another mode without mutating process.env and leaking that into sibling suites - the ambient-environment trap that cost v1.7 a whole test run's validity. Discord's scope is pinned to 'identify' to match passport-discord. SuperTokens' built-in provider would also request 'email'; asking existing players to consent to a new scope mid-rollout is indistinguishable from a phishing prompt and would damage trust in the migration. The compose service is opt-in via profiles, not a depends_on. In the default mode RackStack never contacts the core, and blocking every deployment on a container it will not use turns an unrelated SuperTokens problem into a RackStack outage. FINDING, recorded in the spec: the assumption that SuperTokens' thirdPartyUserId equals passport's profile.id - which section 5.5 called "load-bearing and unverified" - is now verified at source level for both providers. supertokens-node's GitHub provider sets thirdPartyUserId = `${user.id}` and passport-github2 sets profile.id = String(json.id); for Discord, supertokens maps userInfoMap userId to 'id' and passport-discord passes Discord's raw JSON through. Same field, same stringification, both providers. This raises confidence but does NOT retire the shadow gate: what matters is the values already in the owner's identities rows, which may predate these library versions. nodemailer advisory (2 high, via supertokens-node) assessed and accepted, not fixed. No patched 8.x exists and npm's suggested remediation is downgrading supertokens-node from 24 to 9.2.3. The vulnerable surface is message-level `raw`, reachable only when sending email, and nodemailer is referenced only under the emailpassword/emailverification/passwordless/ webauthn SMTP delivery services - verified by grepping the installed package. This release initialises ThirdParty and Session only, so none is ever constructed. Revisit if an email-bearing recipe is ever added. Node 20 engine check passed: supertokens-node introduces no package declaring engines.node > 20. DOCS, updated in the same commit per the owner's instruction. The runbook gains a full "How your existing Discord and GitHub logins carry over" section - that no save is rewritten and no id renumbered, what a player actually experiences in each case (nothing, in every case), the one assumption it rests on and why the shadow gate still exists, and that Discord and GitHub remain separate accounts as they always have. Part B (standing up the core) is now written in full. The plan gains a findings section recording every deviation above; the spec gains the verification note, a scoping correction so the postgres:// warning cannot creep back into applying to DATABASE_URL, and a status block. Verified: test:all green on both backends - sqlite 526 passed/26 skipped, postgres 549 passed/3 skipped (+19 each). All six e2e smoke suites pass, 39 assertions, zero errors. Real boots in both modes: passport serves /auth/authorisationurl as SPA HTML (nothing handles it), dual answers with SuperTokens JSON - proving the conditional mount works in both directions. Not verified: a live SuperTokens core, whose image pull hit a Docker Hub rate limit here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS --- .env.example | 10 + docker-compose.yml | 42 ++ docker/init-supertokens-db.sql | 21 + .../plans/2026-08-06-v1.8-supertokens.md | 91 +++- .../2026-08-01-postgres-supertokens-design.md | 41 +- docs/supertokens-rollout-runbook.md | 137 +++++- package-lock.json | 396 +++++++++++++++++- package.json | 3 +- server/app.js | 31 +- server/index.js | 2 +- server/supertokens/init.js | 113 +++++ server/supertokens/providers.js | 114 +++++ tests/api.events.hotfix.test.js | 2 +- tests/api.events.test.js | 2 +- tests/api.finalfix.test.js | 2 +- tests/api.social.test.js | 2 +- tests/api.test.js | 2 +- tests/api.tutorial.test.js | 2 +- tests/supertokens.init.test.js | 181 ++++++++ 19 files changed, 1153 insertions(+), 41 deletions(-) create mode 100644 docker/init-supertokens-db.sql create mode 100644 server/supertokens/init.js create mode 100644 server/supertokens/providers.js create mode 100644 tests/supertokens.init.test.js diff --git a/.env.example b/.env.example index 55127ae..f7d4598 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,16 @@ SUPER_ADMIN_IDS= # separate database on the same Postgres server, never at the rackstack one. # SUPERTOKENS_CONNECTION_URI=http://supertokens:3567 +# Optional API key, if you configured one on the SuperTokens core. +# SUPERTOKENS_API_KEY= + +# The public origin this server is reached at, e.g. https://rackstack.example.com +# Only needed when AUTH_MODE is dual or supertokens. Leave blank and it is +# derived from GITHUB_CALLBACK_URL / DISCORD_CALLBACK_URL below, which is +# correct for almost everyone - set it explicitly only if you sit behind a +# proxy where the public origin differs from your OAuth callback host. +# PUBLIC_ORIGIN= + # --- Discord OAuth --- # Create an app at https://discord.com/developers/applications # -> OAuth2 -> add a redirect matching DISCORD_CALLBACK_URL below diff --git a/docker-compose.yml b/docker-compose.yml index 8fc6eb4..c974703 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,12 +9,54 @@ services: POSTGRES_DB: rackstack volumes: - ./pgdata:/var/lib/postgresql/data + # Creates the SEPARATE database the SuperTokens core needs (v1.8). + # Scripts here run only when the data directory is first initialised, + # so on an existing deployment this file does nothing and the database + # must be created by hand - see docs/supertokens-rollout-runbook.md. + # Creating it unconditionally is harmless: an unused empty database + # costs nothing, and having it ready removes a step from the rollout. + - ./docker/init-supertokens-db.sql:/docker-entrypoint-initdb.d/10-supertokens.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U rackstack"] interval: 10s timeout: 5s retries: 5 + # The SuperTokens core (v1.8). Opt-in: it belongs to a compose profile, so + # `docker compose up` does NOT start it and the default passport auth stack + # is unaffected. Start it only when you are rolling SuperTokens out: + # + # docker compose --profile supertokens up -d + # + # It is deliberately not a `depends_on` of rackstack. In the default + # AUTH_MODE the server never contacts it, and making every deployment wait + # on a container it will not use is a good way to turn an unrelated + # SuperTokens problem into a RackStack outage. + supertokens: + image: registry.supertokens.io/supertokens/supertokens-postgresql:latest + container_name: rackstack-supertokens + profiles: ["supertokens"] + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + ports: + - "3567:3567" + environment: + # Its OWN database, never the rackstack one - SuperTokens manages its + # own schema and must not share a database with application tables. + # + # The scheme must be postgresql://. The SuperTokens core rejects + # postgres:// at startup. (This is specific to the core: RackStack's + # own DATABASE_URL above accepts either - v1.7 verified that directly, + # against an earlier claim to the contrary.) + POSTGRESQL_CONNECTION_URI: postgresql://rackstack:rackstack@postgres:5432/supertokens + healthcheck: + test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/3567' || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + rackstack: build: . container_name: rackstack diff --git a/docker/init-supertokens-db.sql b/docker/init-supertokens-db.sql new file mode 100644 index 0000000..cd35931 --- /dev/null +++ b/docker/init-supertokens-db.sql @@ -0,0 +1,21 @@ +-- Creates the separate database the SuperTokens core requires (v1.8). +-- +-- SuperTokens manages its own schema and must not share a database with +-- RackStack's application tables. This is a different database on the same +-- Postgres server - not a different server, and not a schema inside the +-- rackstack database. +-- +-- Postgres runs everything in /docker-entrypoint-initdb.d exactly once, when +-- the data directory is first initialised. On an existing deployment - which +-- includes every install that already migrated to Postgres in v1.7 - this +-- file never runs, and the database must be created by hand: +-- +-- psql -U rackstack -d rackstack -c 'CREATE DATABASE supertokens OWNER rackstack;' +-- +-- Creating it before it is needed is deliberate and harmless: an empty +-- unused database costs nothing, and it removes a step from the rollout at +-- the moment when fewest steps is worth the most. +-- +-- The owner matches POSTGRES_USER from docker-compose.yml so the SuperTokens +-- core can create its own tables on first connect. +CREATE DATABASE supertokens OWNER rackstack; diff --git a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md index 3785371..8b40273 100644 --- a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md +++ b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md @@ -71,19 +71,19 @@ Establish the switch and prove it contains everything, *before* any SuperTokens **Interfaces:** - Produces: `AUTH_MODES` (frozen `['passport','dual','supertokens']`), `resolveAuthMode(env)`, `isSuperTokensEnabled(mode)`, `isPassportEnabled(mode)`. -- [ ] **Step 1: Write `resolveAuthMode`** +- [x] **Step 1: Write `resolveAuthMode`** Unset or empty → `'passport'`. An unrecognised value must **throw at boot**, not silently fall back — a typo'd `AUTH_MODE=supertoken` that quietly serves the legacy stack is the kind of thing nobody notices until the rollout is presumed complete. The error names the received value and lists the three valid ones. -- [ ] **Step 2: Test it** +- [x] **Step 2: Test it** Cover: default when unset, default when empty string, each of the three valid values, case handling (decide and document: recommend accepting exact lowercase only, and erroring on `Passport` with a message that says so), and the throw on garbage. -- [ ] **Step 3: Document the variable everywhere it can be set** +- [x] **Step 3: Document the variable everywhere it can be set** `.env.example` gets `AUTH_MODE=` (commented, defaulting to passport) with the three-value table and the "rollback is setting this back to passport" note. `unraid-template.xml` gets a `Config` entry — `Display="advanced"`, `Required="false"`, default empty. `README.md` gets the three-value table. `docker-compose.yml` gets `AUTH_MODE: ${AUTH_MODE-passport}` — note the **non-colon** form, per the v1.7 finding that `${VAR:-default}` treats an explicitly empty value as unset and would defeat the documented rollback. -- [ ] **Step 4: Verify containment** +- [x] **Step 4: Verify containment** `npm run test:all` with no `AUTH_MODE` set must pass unchanged. Add an assertion that `resolveAuthMode({})` is `'passport'` so the default can never drift. @@ -99,29 +99,29 @@ Cover: default when unset, default when empty string, each of the three valid va - Consumes: Task 1's `resolveAuthMode`. - Produces: `initSuperTokens({ env })` (idempotent; returns `false` when mode is `passport`), `mountSuperTokens(app, mode)`. -- [ ] **Step 1: Add the dependency** +- [x] **Step 1: Add the dependency** `npm install supertokens-node@^24`. Then confirm the constraint that matters: `npm ls` on **Node 20** must not warn `EBADENGINE` for it or anything it pulls in. If it does, that transitive package cannot be on a CI or production code path — treat it exactly like the `undici` incident and report it rather than bumping Node. -- [ ] **Step 2: `providers.js`** +- [x] **Step 2: `providers.js`** Map the existing `GITHUB_CLIENT_ID`/`SECRET` and `DISCORD_CLIENT_ID`/`SECRET` into SuperTokens' ThirdParty provider config, using `thirdPartyId` values `'github'` and `'discord'` — these must match the `provider` values already stored in `identities`, because Task 3 keys off them. A provider with no credentials is omitted, mirroring `configurePassport`'s behaviour rather than inventing a new one. -- [ ] **Step 3: `init.js`** +- [x] **Step 3: `init.js`** `supertokens.init()` with `Session` and `ThirdParty` recipes only (spec §7 puts every other recipe out of scope). `connectionURI` from `SUPERTOKENS_CONNECTION_URI`; `apiDomain`/`websiteDomain` from existing config. The `supertokens-node` import must be **dynamic**, inside the function, after the mode check — so `AUTH_MODE=passport` never loads the SDK. Make the function idempotent: a second call is a no-op, not a double-init throw. -- [ ] **Step 4: Mount conditionally in `app.js`** +- [x] **Step 4: Mount conditionally in `app.js`** SuperTokens' `middleware()` before the API router and its `errorHandler()` after — both only when `isSuperTokensEnabled(mode)`. In `passport` mode `app.js`'s middleware stack must be identical to today's; assert this in a test rather than by eye. -- [ ] **Step 5: Compose + template + env** +- [x] **Step 5: Compose + template + env** `docker-compose.yml` gains a `supertokens` service (`registry.supertokens.io/supertokens/supertokens-postgresql`, port 3567) with `POSTGRESQL_CONNECTION_URI` pointing at its **own database** on the v1.7 Postgres instance — not the rackstack database. Document both footguns from spec §5.1 in the compose comments and `.env.example`: the scheme must be `postgresql://`, and the host may not be `localhost` from inside a container. Add a healthcheck and make rackstack `depends_on` it only when SuperTokens is in play. > **Note the v1.7 correction:** the `postgres://` scheme is fine for *rackstack's* own `DATABASE_URL` (`pg` accepts both, verified). The SuperTokens **core** is the component that genuinely rejects it. Do not restate the debunked claim; scope the warning to the SuperTokens connection URI. -- [ ] **Step 6: Docs** +- [x] **Step 6: Docs** `README.md` gains a SuperTokens section: what the container is, that it needs its own database, and that none of it is reached in the default mode. @@ -275,3 +275,74 @@ Bump `package.json` to 1.8.0 and the Dockerfile's `org.opencontainers.image.vers - [ ] **Step 4: Full verification** `npm run test:all` green on both backends, all six e2e smoke suites green, and a real boot in each of the three `AUTH_MODE` values. + +--- + +## Findings and deviations + +Recorded as work proceeds, so the plan does not quietly diverge from what was +actually built. + +### Task 2 + +**`buildApp()` became async.** Not anticipated by the plan. SuperTokens' +`middleware()` can only be mounted after `supertokens.init()` has run, and +init imports the SDK dynamically to honour the containment constraint — so the +mount point is necessarily async. All seven call sites (`server/index.js` and +six `tests/api.*.test.js` files) now `await buildApp()`. Task 4 depends on this +having happened. + +**`buildApp({ env })` takes an env override.** Needed so a test can build the +app in a mode other than the process's own, without mutating `process.env` +and leaking that into sibling suites — the ambient-environment trap that cost +v1.7 a whole test run's validity. + +**The Discord scope is pinned to `identify`.** SuperTokens' built-in Discord +provider also requests `email` by default. Requesting a scope existing players +never consented to would re-prompt every returning player for new permissions +mid-rollout, which is indistinguishable from a phishing attempt and would do +real damage to trust in the migration. Pinned to match what +`passport-discord` asks for today. + +**`resolvePublicOrigin` derives the origin from existing callback URLs.** The +plan did not say where `apiDomain`/`websiteDomain` come from. Rather than add +another mandatory variable, the origin is taken from `PUBLIC_ORIGIN` when set, +otherwise parsed out of `GITHUB_CALLBACK_URL`/`DISCORD_CALLBACK_URL`, which +are already mandatory for passport and always carry the public origin. + +**The SuperTokens core is opt-in in compose (`profiles:`), not a +`depends_on`.** In the default auth mode RackStack never contacts it, and +making every deployment block on a container it will not use converts an +unrelated SuperTokens problem into a RackStack outage. + +**`nodemailer` advisory (GHSA, high) accepted, not fixed.** `npm audit` reports +two high-severity entries, both tracing to `nodemailer` via `supertokens-node`. +Assessment: + +- The advisory covers `nodemailer <=9.0.0`; no patched 8.x exists, and npm's + suggested remediation is downgrading `supertokens-node` from 24 to 9.2.3 — a + major downgrade, not a fix. +- The vulnerable surface is the message-level `raw` option, reachable only when + actually sending email. +- `nodemailer` is referenced only under `recipe/{emailverification, + emailpassword,passwordless,webauthn}/emaildelivery/services/smtp` — verified + by grepping the installed package. This release initialises **ThirdParty and + Session only** (design §7), so none of those services is ever constructed and + no email is ever sent. + +Unreachable in this configuration. Revisit if any email-bearing recipe is ever +added — that decision would make this advisory live. + +**Node 20 engine check passed.** The plan's Task 2 Step 1 check: no package in +the tree introduced by `supertokens-node` declares `engines.node > 20`. The +only such package remains `undici@8.9.0` (`>=22.19.0`), which arrives via +Testcontainers and was already isolated behind a dynamic import in v1.7. + +### Correction to the design + +Spec §5.5 called the "SuperTokens `thirdPartyUserId` equals passport's +`profile.id`" assumption "load-bearing and unverified". It is now verified at +the source level for both providers, at the pinned versions — see §5.3 in the +updated spec. This raises confidence but does **not** retire the shadow-mode +gate, because what matters is the values already stored in the owner's +`identities` rows, which may have been written by older library versions. diff --git a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md index 43ba6f3..113a970 100644 --- a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md +++ b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md @@ -247,6 +247,19 @@ python3/make/g++ build stage stays. `DB_PATH` keeps its default so a Starts only after v1.7 is confirmed running in production. +> **Status (2026-08-06).** v1.7 is merged, tagged `v1.7.0`, and published to +> GHCR — but **has not been cut over on the owner's Unraid box**, and the +> production export §5.5 depends on has not been supplied. Implementation of +> v1.8 is under way regardless, because `AUTH_MODE` defaults to `passport` and +> every part of the release is inert until an operator changes it. The two +> things genuinely gated on production are unchanged: running shadow mode +> against real identities, and cutting over to `dual`. Neither has happened. +> +> Implementation plan: `docs/superpowers/plans/2026-08-06-v1.8-supertokens.md`. +> Operator runbook: `docs/supertokens-rollout-runbook.md`. +> Progress: Tasks 1–2 of 7 built (the `AUTH_MODE` switch; SuperTokens init, +> provider config and conditional mounting). + ### 5.1 Containers - `registry.supertokens.io/supertokens/supertokens-postgresql`, port 3567. @@ -256,6 +269,12 @@ Starts only after v1.7 is confirmed running in production. fails at startup), and the host may not be `localhost`/`127.0.0.1` from inside a container. +> **Scoping correction (2026-08-06).** The `postgres://` warning applies to the +> **SuperTokens core only**. v1.7 established that RackStack's own +> `DATABASE_URL` accepts either scheme — `pg-connection-string` parses them +> identically, verified directly — and three documents that had repeated the +> broader claim were corrected in v1.7. Do not let this line reintroduce it. + ### 5.2 Strangler rollout via `AUTH_MODE` | `AUTH_MODE` | Behaviour | @@ -274,7 +293,27 @@ seam means zero route handler changes. SuperTokens' ThirdParty recipe supplies `thirdPartyId` (`'github'`/`'discord'`) and `thirdPartyUserId` — the same pair passport supplies as `provider` and -`profile.id`. In the `signInUp` override: +`profile.id`. + +> **Verified during v1.8 implementation (2026-08-06).** This equality was +> written as an assumption; it has since been checked against the pinned +> library sources: +> +> - **GitHub** — `supertokens-node`'s built-in provider sets +> ``thirdPartyUserId = `${user.id}` `` (the numeric id, stringified); +> `passport-github2` sets `profile.id = String(json.id)`. Same source field, +> same stringification. +> - **Discord** — `supertokens-node` maps +> `userInfoMap.fromUserInfoAPI.userId` to `id` (the snowflake, already a +> string); `passport-discord` passes Discord's raw user JSON straight +> through, so `profile.id` is that same `id`. +> +> This raises confidence but does **not** retire the §5.5 shadow gate: what +> ultimately matters is the values already stored in the owner's `identities` +> rows, some of which may have been written by older versions of either +> library. + +In the `signInUp` override: 1. Look up `identities` by `(provider, provider_id)`. 2. Resolve the existing `users.id` (or create user + identity for a new player). diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index 5f897b4..be24cb8 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -14,7 +14,7 @@ Do not set `AUTH_MODE` to anything but blank or `passport` yet. | Piece | Task | State | |---|---|---| | `AUTH_MODE` switch + validation | 1 | ✅ built | -| SuperTokens init + provider config | 2 | ⬜ not started | +| SuperTokens init + provider config | 2 | ✅ built | | Identity mapping (`signInUp` override) | 3 | ⬜ not started | | Auth middleware chain | 4 | ⬜ not started | | Shadow-mode verification | 5 | ⬜ not started | @@ -26,6 +26,74 @@ Design: [`superpowers/specs/2026-08-01-postgres-supertokens-design.md`](./superp --- +--- + +## How your existing Discord and GitHub logins carry over + +This is the part worth understanding before anything else, because it is what +determines whether a returning player finds their save or a blank one. + +### Nothing about a player's account changes + +RackStack identifies every player by `users.id`, which is the literal string +`provider:providerId` — `github:37058311`, `discord:123456789012345678`. That +id is the primary key of `users`, and it is what `saves`, `roles`, +`event_participation` and `SUPER_ADMIN_IDS` all point at. + +**SuperTokens does not replace it.** It issues its own internal user id, and +then RackStack maps that id *onto* the existing one using SuperTokens' external +user-id mapping. After the mapping, `session.getUserId()` returns +`github:37058311` — exactly what the old JWT carried. Every route, every save +lookup, and your admin access resolve unchanged. + +So: no save is rewritten, no id is renumbered, and no foreign key moves. The +v1.7 `identities` table already stores the `(provider, provider_id)` pairs this +mapping keys off; it shipped unused precisely so this release would be cheap. + +### What a player actually experiences + +| Player | What they see | +|---|---| +| Logged in now, stays logged in | Nothing. Their existing cookie is a 90-day JWT and stays valid through every mode change, in both directions. | +| Logs in again during `dual` | The same Discord/GitHub button. They authorise as usual and land on their existing save. | +| Brand-new player during `dual` | Normal signup; user + identity created, mapping points at the new id. | +| Anyone, if you roll back | Nothing. Rollback does not invalidate sessions. | + +Nobody is asked to re-link, re-authorise, or create anything. There is no +"migrate your account" screen, because there is nothing for a player to do. + +### The one assumption this rests on + +The mapping works only if the id SuperTokens computes for a provider is the +same string passport stored. Both were checked against the pinned library +versions: + +- **GitHub** — SuperTokens' built-in provider sets + ``thirdPartyUserId = `${user.id}` `` (GitHub's numeric id, stringified). + `passport-github2` sets `profile.id = String(json.id)`. Same field, same + stringification. +- **Discord** — SuperTokens maps `userInfoMap.fromUserInfoAPI.userId` to `id`, + the snowflake, already a string. `passport-discord` passes Discord's raw user + JSON through, so `profile.id` is that same `id`. + +That is strong evidence, and it is *not* the same thing as proof. What +ultimately matters is the values actually sitting in your `identities` rows, +some of which may have been written by older versions of those libraries. + +**That gap is exactly what shadow mode (Part C) closes, and why cutover is +gated on it.** If the ids ever disagreed, the symptom would not be an error — +it would be a player quietly landing on a brand-new empty save, which is the +one failure mode this whole release is arranged to prevent. + +### If you have used both Discord and GitHub + +They stay separate accounts, as they do today. `users.id` is per-provider, so +`github:...` and `discord:...` have always been two different players with two +different saves. v1.8 changes nothing here — account linking is explicitly out +of scope (design §7). The schema permits it; no user-facing flow ships. + +--- + ## Prerequisites 1. **v1.7 running in production**, on Postgres, confirmed working. The design @@ -102,18 +170,65 @@ change and stop; do not proceed to Part B. ## Part B — Stand up the SuperTokens core -*Pending Task 2.* +Safe to do at any time. The core sitting there unused changes nothing: with +`AUTH_MODE` blank, RackStack never contacts it. + +### B1. Give it its own database + +SuperTokens manages its own schema and must not share a database with +RackStack's tables. This is a separate **database** on the same Postgres +server — not a separate server, and not a schema inside `rackstack`. + +```bash +psql -U rackstack -d rackstack -c 'CREATE DATABASE supertokens OWNER rackstack;' +``` + +On Docker Compose with a *fresh* `pgdata`, `docker/init-supertokens-db.sql` +does this automatically. On any existing install — which includes every one +that migrated to Postgres in v1.7 — Postgres only runs init scripts when the +data directory is first created, so run the command above by hand. + +### B2. Run the core + +**Compose** — it is behind an opt-in profile, so it does not start by default: + +```bash +docker compose --profile supertokens up -d +``` + +**Unraid** — add a container from +`registry.supertokens.io/supertokens/supertokens-postgresql`, publish port +3567, and set one variable: + +``` +POSTGRESQL_CONNECTION_URI=postgresql://rackstack:PASSWORD@192.168.x.x:5432/supertokens +``` + +Two ways this line goes wrong, both worth reading twice: + +- **The scheme must be `postgresql://`.** The SuperTokens core rejects + `postgres://` at startup. This is specific to the core — RackStack's own + `DATABASE_URL` accepts either, which v1.7 verified directly against an + earlier claim to the contrary. Do not "fix" `DATABASE_URL` on the strength + of this line. +- **Not `localhost`.** Inside a container that means the container itself. + Use the host's LAN IP, or the compose service name. + +### B3. Check it came up + +```bash +curl -s http://127.0.0.1:3567/hello +``` + +Expect `Hello`. If it does not respond, check the core's log for a connection +error against the database from B1 — that is the overwhelmingly common cause. -Will cover: the `registry.supertokens.io/supertokens/supertokens-postgresql` -container on port 3567, giving it its **own** database on the Postgres server -v1.7 stood up (never the rackstack database), and the two connection-string -footguns — the SuperTokens core requires the `postgresql://` scheme and -rejects `postgres://`, and the host may not be `localhost` from inside a -container. +### B4. Point RackStack at it — but do not switch yet -> Note: `postgres://` being rejected is specific to the **SuperTokens core**. -> RackStack's own `DATABASE_URL` accepts either scheme — an earlier draft of -> the design claimed otherwise and v1.7 disproved it. +Set `SUPERTOKENS_CONNECTION_URI` on the RackStack container. **Leave +`AUTH_MODE` blank.** The variable is only read in `dual`/`supertokens`, so +setting it now is inert and gets the configuration out of the way before the +step that actually changes behaviour. ## Part C — Shadow-mode verification gate diff --git a/package-lock.json b/package-lock.json index f38e0c0..ea71c4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rackstack-server", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rackstack-server", - "version": "1.6.0", + "version": "1.7.0", "dependencies": { "better-sqlite3": "^11.3.0", "cookie-parser": "^1.4.6", @@ -16,7 +16,8 @@ "passport": "^0.7.0", "passport-discord": "^0.1.4", "passport-github2": "^0.1.12", - "pg": "^8.22.0" + "pg": "^8.22.0", + "supertokens-node": "^24.0.3" }, "devDependencies": { "@testcontainers/postgresql": "^12.0.4", @@ -1292,6 +1293,41 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1504,9 +1540,20 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -1977,7 +2024,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -2202,6 +2248,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2217,6 +2272,19 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2264,7 +2332,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -2495,7 +2562,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2735,6 +2801,26 @@ "node": ">= 0.8" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2756,7 +2842,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2958,7 +3043,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -3002,6 +3086,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -3108,6 +3228,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", @@ -3210,6 +3339,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.10", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.10.tgz", + "integrity": "sha512-xJxrdqvbl2rtn2MaUJrUejz8J7/uZNC0V77oks2LxYrO/+ZtVpRmz+fEQMuu6VusnEB1fmpByiLS1WXecOnAnw==", + "license": "MIT" + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -3495,6 +3630,35 @@ "node": ">=10" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nodemailer": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3551,6 +3715,22 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -3791,6 +3971,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-3.1.0.tgz", + "integrity": "sha512-bQ/0XPZZ7eX+cdAkd61uYWpfMhakH3NeteUF1R8GNa+LMqX8QFAkbCLqq+AYAns1/ueACBu/BMWhrlKGrdvGZg==", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.1.1" + } + }, "node_modules/postcss": { "version": "8.5.23", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", @@ -3890,7 +4079,6 @@ "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -3977,6 +4165,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4003,6 +4200,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4089,6 +4292,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -4170,6 +4379,13 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scmp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", + "deprecated": "Just use Node.js's crypto.timingSafeEqual()", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -4227,6 +4443,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -4713,6 +4935,82 @@ "node": ">=6.6.0" } }, + "node_modules/supertokens-js-override": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/supertokens-js-override/-/supertokens-js-override-0.0.4.tgz", + "integrity": "sha512-r0JFBjkMIdep3Lbk3JA+MpnpuOtw4RSyrlRAbrzMcxwiYco3GFWl/daimQZ5b1forOiUODpOlXbSOljP/oyurg==", + "license": "Apache-2.0" + }, + "node_modules/supertokens-node": { + "version": "24.0.3", + "resolved": "https://registry.npmjs.org/supertokens-node/-/supertokens-node-24.0.3.tgz", + "integrity": "sha512-bidA9avgA5gBSus4p/H2HHahARj44qGsRJeXN8p7NMqjDN+F80aBsOF/aozB+xnm+ELOKQlQFTDgcAG8Ag/5QQ==", + "license": "Apache-2.0", + "dependencies": { + "buffer": "^6.0.3", + "content-type": "^1.0.5", + "cookie": "^0.7.2", + "cross-fetch": "^4.1.0", + "debug": "^4.3.3", + "jose": "^4.13.1", + "libphonenumber-js": "^1.9.44", + "nodemailer": "^8.0.2", + "pako": "^2.1.0", + "pkce-challenge": "^3.0.0", + "process": "^0.11.10", + "set-cookie-parser": "^2.7.1", + "supertokens-js-override": "^0.0.4", + "tldts": "^6.1.48", + "twilio": "^4.19.3" + } + }, + "node_modules/supertokens-node/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/supertokens-node/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/supertokens-node/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/tar-fs": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", @@ -4899,6 +5197,24 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -4918,6 +5234,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -4937,6 +5259,25 @@ "dev": true, "license": "Unlicense" }, + "node_modules/twilio": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-4.23.0.tgz", + "integrity": "sha512-LdNBQfOe0dY2oJH2sAsrxazpgfFQo5yXGxe96QA8UWB5uu+433PrUbkv8gQ5RmrRCqUTPQ0aOrIyAdBr1aB03Q==", + "license": "MIT", + "dependencies": { + "axios": "^1.6.0", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.0", + "qs": "^6.9.4", + "scmp": "^2.1.0", + "url-parse": "^1.5.9", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -4982,6 +5323,16 @@ "node": ">= 0.8" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5227,6 +5578,22 @@ "dev": true, "license": "MIT" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5364,6 +5731,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xmlbuilder": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 12e8505..51c031b 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "passport": "^0.7.0", "passport-discord": "^0.1.4", "passport-github2": "^0.1.12", - "pg": "^8.22.0" + "pg": "^8.22.0", + "supertokens-node": "^24.0.3" }, "devDependencies": { "@testcontainers/postgresql": "^12.0.4", diff --git a/server/app.js b/server/app.js index 1249b96..ef7f961 100644 --- a/server/app.js +++ b/server/app.js @@ -4,6 +4,8 @@ import cookieParser from 'cookie-parser'; import path from 'path'; import { fileURLToPath } from 'url'; import { configurePassport } from './auth.js'; +import { resolveAuthMode, isSuperTokensEnabled } from './authMode.js'; +import { initSuperTokens } from './supertokens/init.js'; import apiRouter from './routes/api.js'; import './db.js'; // ensures tables exist on boot @@ -13,17 +15,44 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); * Builds and returns a fully-configured Express app (middleware + routes + * static client + SPA fallback), without binding a port. Factored out of * index.js so tests can exercise the app with supertest directly. + * + * Async since v1.8: SuperTokens' middleware can only be mounted after + * supertokens.init() has run, and init imports the SDK dynamically so that + * the default `passport` mode never loads it. In `passport` mode this + * function does exactly what it did before - same middleware, same order, + * nothing extra on the stack. */ -export function buildApp() { +export async function buildApp({ env = process.env } = {}) { const app = express(); + const mode = resolveAuthMode(env); configurePassport(); app.use(passport.initialize()); app.use(cookieParser()); app.use(express.json({ limit: '256kb' })); + // SuperTokens' middleware must sit BEFORE the API router: it serves the + // /auth/* endpoints (including the OAuth callbacks) that the router would + // otherwise fall through on, and it is what populates the session the auth + // chain reads. Its errorHandler goes after the router, below. + if (isSuperTokensEnabled(mode)) { + await initSuperTokens({ env, mode }); + const { middleware } = await import('supertokens-node/framework/express'); + app.use(middleware()); + } + app.use('/', apiRouter); + if (isSuperTokensEnabled(mode)) { + // Translates SuperTokens' own errors (expired session, unauthorised, + // token theft) into its documented responses. Registered after the + // router so it only sees what the router did not handle, and never in + // passport mode - where an extra error handler on the stack could change + // how existing errors surface. + const { errorHandler } = await import('supertokens-node/framework/express'); + app.use(errorHandler()); + } + // Serve the built client (client/dist, produced by `npm run build` in client/) const CLIENT_DIST = path.join(__dirname, '..', 'client', 'dist'); app.use(express.static(CLIENT_DIST)); diff --git a/server/index.js b/server/index.js index 4fac127..8720311 100644 --- a/server/index.js +++ b/server/index.js @@ -78,7 +78,7 @@ await runScheduler(Date.now()); // setInterval is an unhandled rejection that crashes Node 20 by default. setInterval(() => { runScheduler(Date.now()).catch((e) => console.error('[scheduler]', e)); }, 3600_000).unref(); -const app = buildApp(); +const app = await buildApp(); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { diff --git a/server/supertokens/init.js b/server/supertokens/init.js new file mode 100644 index 0000000..a884d7e --- /dev/null +++ b/server/supertokens/init.js @@ -0,0 +1,113 @@ +// SuperTokens initialisation, kept entirely behind the AUTH_MODE guard. +// +// Every import of supertokens-node in this file is DYNAMIC and happens +// inside initSuperTokens(), after the mode check. That is a hard requirement, +// not a style preference: +// +// 1. In the default `passport` mode the SDK must not be loaded at all, so +// that upgrading to v1.8 cannot change behaviour, startup time, or +// memory footprint for operators who never opt in. +// 2. supertokens-node pulls in a large transitive tree (nodemailer, twilio, +// libphonenumber-js) that exists only to serve recipes this project +// never initialises. v1.7 shipped CI that was silently red for four +// commits because a top-level import pulled in a package that could not +// load on the runtime's Node version. A static import here would put +// that whole tree on the boot path of every deployment, opted in or not. +// +// Recipes are ThirdParty and Session ONLY. The design (section 7) puts every +// other recipe out of scope, and that boundary is what keeps the nodemailer +// advisory in supertokens-node's dependency tree unreachable: nodemailer is +// referenced solely by the emailpassword / emailverification / passwordless / +// webauthn SMTP delivery services, none of which are ever constructed here. + +import { isSuperTokensEnabled } from '../authMode.js'; +import { buildProviders, resolvePublicOrigin } from './providers.js'; + +// SuperTokens' own default API base path. It is also why the runbook widens +// the GitHub OAuth registration to /auth: SuperTokens serves its callbacks at +// `${apiBasePath}/callback/`, i.e. /auth/callback/github, while +// passport uses /auth/github/callback. Both are subdirectories of /auth. +export const API_BASE_PATH = '/auth'; + +let initialised = false; + +/** + * Initialises SuperTokens if the mode calls for it. + * + * Returns true when SuperTokens is now active, false when the mode means it + * should stay dormant. Idempotent: calling twice is a no-op rather than a + * double-init error, because buildApp() is called per-test as well as once at + * boot, and a second call throwing would make the app untestable. + */ +export async function initSuperTokens({ env = process.env, mode } = {}) { + if (!isSuperTokensEnabled(mode)) return false; + if (initialised) return true; + + const connectionURI = env.SUPERTOKENS_CONNECTION_URI; + if (!connectionURI) { + throw new Error( + `AUTH_MODE='${mode}' requires SUPERTOKENS_CONNECTION_URI to be set ` + + '(the SuperTokens core, e.g. http://supertokens:3567). ' + + "Set AUTH_MODE=passport to run without SuperTokens.", + ); + } + + const origin = resolvePublicOrigin(env); + if (!origin) { + throw new Error( + `AUTH_MODE='${mode}' needs to know this server's public origin. ` + + 'Set PUBLIC_ORIGIN (e.g. https://rackstack.example.com), or configure ' + + 'GITHUB_CALLBACK_URL / DISCORD_CALLBACK_URL as you would for passport.', + ); + } + + const providers = buildProviders(env); + if (providers.length === 0) { + throw new Error( + `AUTH_MODE='${mode}' but no OAuth provider is configured. Set ` + + 'GITHUB_CLIENT_ID/SECRET and/or DISCORD_CLIENT_ID/SECRET - otherwise ' + + 'SuperTokens would start with no way for anyone to log in.', + ); + } + + const [supertokens, Session, ThirdParty] = await Promise.all([ + import('supertokens-node').then((m) => m.default ?? m), + import('supertokens-node/recipe/session').then((m) => m.default ?? m), + import('supertokens-node/recipe/thirdparty').then((m) => m.default ?? m), + ]); + + supertokens.init({ + supertokens: { connectionURI, apiKey: env.SUPERTOKENS_API_KEY || undefined }, + appInfo: { + appName: 'RackStack', + apiDomain: origin, + websiteDomain: origin, + apiBasePath: API_BASE_PATH, + websiteBasePath: '/', + }, + recipeList: [ + ThirdParty.init({ signInUpFeature: { providers } }), + Session.init(), + ], + }); + + initialised = true; + return true; +} + +/** Test-only: whether init has run in this process. */ +export function __isInitialised() { + return initialised; +} + +/** + * Test-only: forget that init ran. + * + * supertokens.init() keeps module-level state inside the SDK that cannot be + * torn down, so this does NOT un-initialise SuperTokens - it only resets this + * module's guard. Tests that need a genuinely clean SDK must run in their own + * process (vitest isolates by file, which is enough). + */ +export function __resetForTests() { + initialised = false; +} diff --git a/server/supertokens/providers.js b/server/supertokens/providers.js new file mode 100644 index 0000000..37fd370 --- /dev/null +++ b/server/supertokens/providers.js @@ -0,0 +1,114 @@ +// SuperTokens ThirdParty provider configuration, built from the SAME +// environment variables passport already uses. +// +// Reusing the credentials is deliberate: during the `dual` rollout both +// stacks talk to the same GitHub and Discord OAuth apps, and an operator +// juggling two sets of client secrets for one provider is an outage waiting +// to happen. The only thing that differs between the stacks is the redirect +// path, which is why the runbook's Part A widens the registered callback +// rather than replacing it. +// +// The `thirdPartyId` values below are load-bearing. They must equal the +// `provider` values already stored in the `identities` table ('github', +// 'discord'), because the signInUp override looks identities up by +// (provider, provider_id) to resolve an existing player's users.id. A +// mismatch here means every existing player is treated as brand new and +// lands on an empty save - the exact failure the rollout exists to avoid. + +/** + * Whether the id SuperTokens computes for a provider is the same string + * passport stored as `provider_id`. Verified against both SDKs at the + * versions pinned in package.json: + * + * github - supertokens-node's built-in provider sets + * thirdPartyUserId = `${user.id}` (stringified numeric id); + * passport-github2 sets profile.id = String(json.id). + * Same source field, same stringification. + * discord - supertokens-node maps userInfoMap.fromUserInfoAPI.userId + * to 'id' (the snowflake, already a string); passport-discord + * passes Discord's raw user JSON through, so profile.id is that + * same 'id'. + * + * This is strong evidence, not proof: what ultimately matters is the values + * actually sitting in the owner's `identities` rows, which may predate + * either library version. Shadow mode (Task 5) is what closes that gap, and + * cutover stays gated on it. + */ +export const PROVIDER_IDS = Object.freeze(['github', 'discord']); + +/** + * Builds the ProviderInput list for ThirdParty.init from `env`. + * + * A provider with no credentials is omitted rather than half-configured, + * mirroring configurePassport()'s behaviour exactly - an operator who runs + * Discord-only today must not suddenly be required to supply GitHub + * credentials to enable SuperTokens. + */ +export function buildProviders(env = process.env) { + const providers = []; + + if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) { + providers.push({ + config: { + thirdPartyId: 'github', + clients: [{ + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + }], + }, + }); + } + + if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) { + providers.push({ + config: { + thirdPartyId: 'discord', + clients: [{ + clientId: env.DISCORD_CLIENT_ID, + clientSecret: env.DISCORD_CLIENT_SECRET, + // 'identify' alone matches what passport-discord requests today. + // SuperTokens' built-in Discord provider defaults to also asking + // for 'email'; requesting a scope the existing OAuth app's users + // have not consented to would re-prompt every returning player for + // new permissions mid-rollout, which looks exactly like a phishing + // attempt and would tank trust in the migration. + scope: ['identify'], + }], + }, + }); + } + + return providers; +} + +/** + * The origin SuperTokens needs for apiDomain/websiteDomain. + * + * Derived from the callback URLs the operator has already configured rather + * than demanding a new variable, because those are mandatory today and are + * guaranteed to carry the public origin. An explicit PUBLIC_ORIGIN wins when + * set, for deployments behind a proxy where the two legitimately differ. + * + * Returns undefined when nothing is configured - the caller decides whether + * that is fatal, which depends on the auth mode. + */ +export function resolvePublicOrigin(env = process.env) { + if (env.PUBLIC_ORIGIN) return stripTrailingSlash(env.PUBLIC_ORIGIN); + + for (const key of ['GITHUB_CALLBACK_URL', 'DISCORD_CALLBACK_URL']) { + const raw = env[key]; + if (!raw) continue; + try { + return new URL(raw).origin; + } catch { + // A malformed callback URL is the operator's problem to fix, but it + // must not take out origin resolution when the other provider's URL is + // perfectly good. + } + } + return undefined; +} + +function stripTrailingSlash(s) { + return s.endsWith('/') ? s.slice(0, -1) : s; +} diff --git a/tests/api.events.hotfix.test.js b/tests/api.events.hotfix.test.js index df731ce..5cb8c0d 100644 --- a/tests/api.events.hotfix.test.js +++ b/tests/api.events.hotfix.test.js @@ -31,7 +31,7 @@ const { activateEvent } = await import('../server/eventService.js'); const { COOKIE_NAME } = await import('../server/auth.js'); await ensureConfig(); -const app = buildApp(); +const app = await buildApp(); afterAll(async () => { if (driver.__backend === 'pg') await driver.__raw.end(); diff --git a/tests/api.events.test.js b/tests/api.events.test.js index 91a9959..2ef4ae2 100644 --- a/tests/api.events.test.js +++ b/tests/api.events.test.js @@ -17,7 +17,7 @@ const { upsertUser, setRoles, driver } = await import('../server/db.js'); const { COOKIE_NAME } = await import('../server/auth.js'); await ensureConfig(); -const app = buildApp(); +const app = await buildApp(); afterAll(async () => { if (driver.__backend === 'pg') await driver.__raw.end(); diff --git a/tests/api.finalfix.test.js b/tests/api.finalfix.test.js index 1060e24..f705a13 100644 --- a/tests/api.finalfix.test.js +++ b/tests/api.finalfix.test.js @@ -25,7 +25,7 @@ const { COOKIE_NAME } = await import('../server/auth.js'); const { activateEvent, endEvent } = await import('../server/eventService.js'); await ensureConfig(); -const app = buildApp(); +const app = await buildApp(); afterAll(async () => { if (driver.__backend === 'pg') await driver.__raw.end(); diff --git a/tests/api.social.test.js b/tests/api.social.test.js index aec28d9..2ab70e2 100644 --- a/tests/api.social.test.js +++ b/tests/api.social.test.js @@ -18,7 +18,7 @@ const { COOKIE_NAME } = await import('../server/auth.js'); const { initialState } = await import('../shared/state.js'); await ensureConfig(); -const app = buildApp(); +const app = await buildApp(); afterAll(async () => { if (driver.__backend === 'pg') await driver.__raw.end(); diff --git a/tests/api.test.js b/tests/api.test.js index 053d66d..c25fd1a 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -25,7 +25,7 @@ const { COOKIE_NAME } = await import('../server/auth.js'); const v11Fixture = JSON.parse(readFileSync(new URL('./fixtures/v11-save.json', import.meta.url))); await ensureConfig(); -const app = buildApp(); +const app = await buildApp(); afterAll(async () => { if (driver.__backend === 'pg') await driver.__raw.end(); diff --git a/tests/api.tutorial.test.js b/tests/api.tutorial.test.js index f02d446..f8e9053 100644 --- a/tests/api.tutorial.test.js +++ b/tests/api.tutorial.test.js @@ -17,7 +17,7 @@ const { COOKIE_NAME } = await import('../server/auth.js'); const { TOUR_IDS, ONBOARDING_TOUR_ID } = await import('../shared/tours.js'); await ensureConfig(); -const app = buildApp(); +const app = await buildApp(); afterAll(async () => { if (driver.__backend === 'pg') await driver.__raw.end(); diff --git a/tests/supertokens.init.test.js b/tests/supertokens.init.test.js new file mode 100644 index 0000000..ba5a6cd --- /dev/null +++ b/tests/supertokens.init.test.js @@ -0,0 +1,181 @@ +process.env.JWT_SECRET = 'test-secret-st-init'; + +import { describe, it, expect, beforeEach } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { buildProviders, resolvePublicOrigin, PROVIDER_IDS } from '../server/supertokens/providers.js'; +import { initSuperTokens, __isInitialised, __resetForTests } from '../server/supertokens/init.js'; + +const CREDS = { + GITHUB_CLIENT_ID: 'gh-id', + GITHUB_CLIENT_SECRET: 'gh-secret', + DISCORD_CLIENT_ID: 'dc-id', + DISCORD_CLIENT_SECRET: 'dc-secret', + PUBLIC_ORIGIN: 'https://rackstack.example.com', + SUPERTOKENS_CONNECTION_URI: 'http://supertokens:3567', +}; + +beforeEach(() => { __resetForTests(); }); + +describe('buildProviders', () => { + it('uses the same thirdPartyId strings stored in the identities table', () => { + // The whole migration hinges on this: signInUp looks identities up by + // (provider, provider_id), and `provider` is 'github'/'discord' as + // written by passport. A different id here makes every existing player + // look brand new. + const ids = buildProviders(CREDS).map((p) => p.config.thirdPartyId); + expect(ids.sort()).toEqual(['discord', 'github']); + for (const id of ids) expect(PROVIDER_IDS).toContain(id); + }); + + it('omits a provider with no credentials, mirroring configurePassport', () => { + const githubOnly = buildProviders({ + GITHUB_CLIENT_ID: 'x', GITHUB_CLIENT_SECRET: 'y', + }); + expect(githubOnly.map((p) => p.config.thirdPartyId)).toEqual(['github']); + + const discordOnly = buildProviders({ + DISCORD_CLIENT_ID: 'x', DISCORD_CLIENT_SECRET: 'y', + }); + expect(discordOnly.map((p) => p.config.thirdPartyId)).toEqual(['discord']); + }); + + it('omits a provider with an id but no secret, rather than half-configuring it', () => { + expect(buildProviders({ GITHUB_CLIENT_ID: 'x' })).toEqual([]); + expect(buildProviders({ GITHUB_CLIENT_SECRET: 'y' })).toEqual([]); + }); + + it('returns an empty list when nothing is configured', () => { + expect(buildProviders({})).toEqual([]); + }); + + it('carries the credentials through unchanged', () => { + const [gh] = buildProviders({ GITHUB_CLIENT_ID: 'abc', GITHUB_CLIENT_SECRET: 'def' }); + expect(gh.config.clients).toEqual([{ clientId: 'abc', clientSecret: 'def' }]); + }); + + it('requests only the identify scope for Discord', () => { + // SuperTokens' built-in Discord provider would also ask for 'email'. + // Requesting a scope existing players never consented to re-prompts them + // for new permissions mid-rollout, which looks like a phishing attempt. + const [dc] = buildProviders({ DISCORD_CLIENT_ID: 'a', DISCORD_CLIENT_SECRET: 'b' }); + expect(dc.config.clients[0].scope).toEqual(['identify']); + }); +}); + +describe('resolvePublicOrigin', () => { + it('prefers an explicit PUBLIC_ORIGIN', () => { + expect(resolvePublicOrigin({ + PUBLIC_ORIGIN: 'https://explicit.example.com', + GITHUB_CALLBACK_URL: 'https://other.example.com/auth/github/callback', + })).toBe('https://explicit.example.com'); + }); + + it('strips a trailing slash from PUBLIC_ORIGIN', () => { + expect(resolvePublicOrigin({ PUBLIC_ORIGIN: 'https://x.example.com/' })) + .toBe('https://x.example.com'); + }); + + it('falls back to the origin of an existing callback URL', () => { + // Avoids demanding a new mandatory variable from operators who already + // have these configured for passport. + expect(resolvePublicOrigin({ + GITHUB_CALLBACK_URL: 'https://rackstack.example.com/auth/github/callback', + })).toBe('https://rackstack.example.com'); + + expect(resolvePublicOrigin({ + DISCORD_CALLBACK_URL: 'https://rackstack.example.com/auth/discord/callback', + })).toBe('https://rackstack.example.com'); + }); + + it('skips a malformed callback URL and uses the other provider\'s', () => { + expect(resolvePublicOrigin({ + GITHUB_CALLBACK_URL: 'not a url', + DISCORD_CALLBACK_URL: 'https://good.example.com/auth/discord/callback', + })).toBe('https://good.example.com'); + }); + + it('returns undefined when nothing is configured', () => { + expect(resolvePublicOrigin({})).toBeUndefined(); + }); +}); + +describe('initSuperTokens containment', () => { + it('does nothing in passport mode, even with everything configured', async () => { + await expect(initSuperTokens({ env: CREDS, mode: 'passport' })).resolves.toBe(false); + expect(__isInitialised()).toBe(false); + }); + + it('imports the SDK only dynamically, so passport mode never loads it', () => { + // The containment guarantee. Asserted at the source level on purpose: + // probing Node's module registry from ESM is unreliable (there is no + // portable way to ask "was this specifier resolved?"), and a probe that + // silently answers "no" would make this test pass no matter what the + // code did - the exact vacuous shape this project keeps finding. + // + // A static `import ... from 'supertokens-node...'` in init.js would put + // the SDK, and its nodemailer/twilio/libphonenumber transitive tree, on + // the boot path of every deployment including those that never opt in. + // This fails the moment one is added. + const src = readFileSync(new URL('../server/supertokens/init.js', import.meta.url), 'utf8'); + + const staticImports = [...src.matchAll(/^\s*import\s[^\n]*?from\s+'([^']+)'/gm)] + .map((m) => m[1]); + expect(staticImports).not.toContain('supertokens-node'); + expect(staticImports.filter((s) => s.startsWith('supertokens-node'))).toEqual([]); + + // ...and it must still be imported dynamically somewhere, or this test + // would also pass against a file that had dropped SuperTokens entirely. + expect(src).toMatch(/import\('supertokens-node'\)/); + }); + + it('mounts nothing extra on the app in passport mode', async () => { + // app.js's middleware stack in passport mode must be what it was before + // v1.8 existed. Comparing route-layer counts is the cheapest way to see + // an accidental extra `app.use`. + const { buildApp } = await import('../server/app.js'); + const app = await buildApp({ env: { ...process.env, AUTH_MODE: 'passport' } }); + const layerNames = app._router.stack.map((l) => l.name); + expect(layerNames).not.toContain('middleware'); + expect(layerNames).not.toContain('errorHandler'); + }); +}); + +describe('initSuperTokens configuration errors', () => { + it('refuses to start without a SuperTokens core URI', async () => { + const { SUPERTOKENS_CONNECTION_URI: _omit, ...env } = CREDS; + await expect(initSuperTokens({ env, mode: 'dual' })) + .rejects.toThrow(/SUPERTOKENS_CONNECTION_URI/); + }); + + it('names the way out in the error, rather than only the problem', async () => { + const { SUPERTOKENS_CONNECTION_URI: _omit, ...env } = CREDS; + await expect(initSuperTokens({ env, mode: 'dual' })) + .rejects.toThrow(/AUTH_MODE=passport/); + }); + + it('refuses when it cannot determine the public origin', async () => { + const { + PUBLIC_ORIGIN: _a, GITHUB_CALLBACK_URL: _b, DISCORD_CALLBACK_URL: _c, ...env + } = CREDS; + await expect(initSuperTokens({ env, mode: 'dual' })) + .rejects.toThrow(/public origin/i); + }); + + it('refuses when no OAuth provider is configured', async () => { + // Otherwise SuperTokens comes up healthy with no way for anyone to log + // in - a failure that looks like success until a player tries. + await expect(initSuperTokens({ + env: { + SUPERTOKENS_CONNECTION_URI: 'http://supertokens:3567', + PUBLIC_ORIGIN: 'https://x.example.com', + }, + mode: 'dual', + })).rejects.toThrow(/no OAuth provider is configured/); + }); + + it('does not mark itself initialised after a failed init', async () => { + const { SUPERTOKENS_CONNECTION_URI: _omit, ...env } = CREDS; + await expect(initSuperTokens({ env, mode: 'dual' })).rejects.toThrow(); + expect(__isInitialised()).toBe(false); + }); +}); From 75af294d3cc79171a9addb4b9229cfc2967d1ce6 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 11:18:03 -0400 Subject: [PATCH 04/14] v1.8: make CI actually prove supertokens-node loads on Node 20 Gap found while reviewing the previous commit's own claim. Task 2 asserted the Node 20 engine check passed, but no test ever imported the SDK: the containment tests run in passport mode, and every configuration-error test throws at init.js's validation - all of which sits BEFORE the dynamic import. The suite would therefore have been fully green on a runtime where supertokens-node could not be loaded at all. That is the exact shape of the v1.7 failure, where four commits of CI were silently red because the broken import lived on a path no green test exercised. An engines field is a claim by the package; loading it is the check. Adds two tests that import supertokens-node, both recipes, and the express framework bindings app.js mounts, asserting each exposes what is used. CI runs Node 20 to match the production image, so this is what makes CI meaningful for this dependency rather than merely passing. Does not weaken the containment assertions: those are source-level (init.js must contain no static import of the SDK), not module-registry-level, and vitest isolates by file. 21 tests in the file, green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS --- tests/supertokens.init.test.js | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/supertokens.init.test.js b/tests/supertokens.init.test.js index ba5a6cd..792807d 100644 --- a/tests/supertokens.init.test.js +++ b/tests/supertokens.init.test.js @@ -140,6 +140,42 @@ describe('initSuperTokens containment', () => { }); }); +describe('the SDK actually loads on this runtime', () => { + // Deliberately separate from the containment tests above, and deliberately + // NOT redundant with them. + // + // Every other test in this file either runs in passport mode or hits a + // config error that throws before init.js reaches its dynamic import - so + // without this, the suite would be fully green on a runtime where + // supertokens-node cannot even be loaded. That is precisely how v1.7 + // shipped four commits of silently-red CI: the failing import was on a path + // no green test exercised. + // + // CI runs Node 20 to match the production image, so this is the check that + // makes CI meaningful for the dependency, rather than merely passing. + // + // Loading the SDK here does not weaken the containment assertions above: + // those are source-level (init.js must contain no static import), not + // module-registry-level, and vitest isolates by file. + + it('imports supertokens-node and both recipes without throwing', async () => { + const [core, session, thirdparty] = await Promise.all([ + import('supertokens-node').then((m) => m.default ?? m), + import('supertokens-node/recipe/session').then((m) => m.default ?? m), + import('supertokens-node/recipe/thirdparty').then((m) => m.default ?? m), + ]); + expect(typeof core.init).toBe('function'); + expect(typeof session.init).toBe('function'); + expect(typeof thirdparty.init).toBe('function'); + }); + + it('exposes the express framework bindings app.js mounts', async () => { + const { middleware, errorHandler } = await import('supertokens-node/framework/express'); + expect(typeof middleware).toBe('function'); + expect(typeof errorHandler).toBe('function'); + }); +}); + describe('initSuperTokens configuration errors', () => { it('refuses to start without a SuperTokens core URI', async () => { const { SUPERTOKENS_CONNECTION_URI: _omit, ...env } = CREDS; From c1639482219a2cb325283e60baf87f4737e70337 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 18:38:44 -0400 Subject: [PATCH 05/14] v1.8 Task 3: identity mapping, ordered before the session The mechanism the release hinges on. SuperTokens mints its own internal user id; RackStack's users.id is `provider:providerId` and is the target of three FKs, every save row and every SUPER_ADMIN_IDS entry. createUserIdMapping reconciles them in one direction only - SuperTokens' id is mapped onto ours. The core rewrites user ids in every response once a mapping exists, so the mapping has to be created before the session is. Hence the override sits on the ThirdParty recipe function, not the API: SuperTokens creates the session in the API layer, after the recipe function returns. Two db interface functions on both drivers: getIdentity (side-effect-free, so the override can ask "does this player exist?" without creating them) and setSupertokensUserId (idempotent on re-login; a row rewriting its own value does not conflict with itself). Three things the plan did not anticipate: - The SDK parameter is `superTokensUserId`, capital T. The lowercase spelling is accepted silently as undefined and no mapping is ever created - which fails as the invisible wrong-save bug, not as an error. Design doc and plan both corrected. - The existing-mapping check has to run first. On a returning login the core hands back the EXTERNAL id, so createUserIdMapping reports UNKNOWN_SUPERTOKENS_USER_ID_ERROR for the correct steady state. Treating that as failure would break every login after the first. - The ordering test is vacuous against a dropped `await` unless the fake core is genuinely async. Found by mutation, not inspection: with an instantly-resolving fake the dangling promise won the race anyway. Every fake core method now crosses a macrotask boundary, and a comment says why. Verified by mutation in both directions, plus a negative control in the suite that wires the same logic after session creation and proves the end state is identical while the session id is permanently wrong. 546 tests green on SQLite, 569 on Postgres. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-06-v1.8-supertokens.md | 69 +++- .../2026-08-01-postgres-supertokens-design.md | 23 +- server/db/driver.pg.js | 43 ++ server/db/driver.sqlite.js | 44 +++ server/db/index.js | 1 + server/db/interface.md | 29 +- server/supertokens/init.js | 12 +- server/supertokens/mapping.js | 238 +++++++++++ tests/db.identities.test.js | 78 ++++ tests/db.interface.test.js | 1 + tests/supertokens.mapping.test.js | 373 ++++++++++++++++++ 11 files changed, 901 insertions(+), 10 deletions(-) create mode 100644 server/supertokens/mapping.js create mode 100644 tests/supertokens.mapping.test.js diff --git a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md index 8b40273..4bb9c6c 100644 --- a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md +++ b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md @@ -141,11 +141,11 @@ The highest-risk task in the release. Read spec §5.3 in full before starting. - `getIdentity(provider, providerId)` → the identity row or `undefined` - `setSupertokensUserId(provider, providerId, supertokensUserId)` → void -- [ ] **Step 1: Add the two db interface functions** +- [x] **Step 1: Add the two db interface functions** Both drivers, same semantics, `undefined` for a miss (matching the v1.7 constraint). `setSupertokensUserId` must tolerate being called twice with the same value (re-login) without violating the `UNIQUE` constraint — decide between an idempotent guard and an upsert, and test the re-login path explicitly. -- [ ] **Step 2: Write the `signInUp` recipe-function override** +- [x] **Step 2: Write the `signInUp` recipe-function override** Order is the whole point: @@ -158,18 +158,18 @@ Order is the whole point: Put this in the **recipe-function** override, not the API override — spec §5.3 is explicit, because the API override runs after the session already exists. -- [ ] **Step 3: Test the ordering directly** +- [x] **Step 3: Test the ordering directly** This is the test the release hinges on, and it must be impossible to pass by accident. Do not assert "a mapping exists at the end" — that passes even if the session was issued first. Instrument the call order: record the sequence of `createUserIdMapping` and session-creation calls and assert mapping precedes session. A test that would still pass with the two lines swapped is not a test of this requirement. -- [ ] **Step 4: Test the identity outcomes** +- [x] **Step 4: Test the identity outcomes** - Existing player, existing identity → `session.getUserId()` is their **existing** `users.id`, and no new user row is created. - Brand-new player → user + identity created, mapping points at the new `users.id`. - Same player logging in twice → no duplicate identity, no `UNIQUE` violation on `supertokens_user_id`. - A player who exists via passport but has never used SuperTokens → resolves to the same save. This is the whole point of the release; assert on the save contents, not just the id. -- [ ] **Step 5: Update `interface.md`** +- [x] **Step 5: Update `interface.md`** Both new functions, with the `undefined`-on-miss contract and the re-login idempotence note. @@ -338,6 +338,65 @@ the tree introduced by `supertokens-node` declares `engines.node > 20`. The only such package remains `undici@8.9.0` (`>=22.19.0`), which arrives via Testcontainers and was already isolated behind a dynamic import in v1.7. +### Task 3 + +**The SDK parameter is `superTokensUserId`, with a capital T.** The plan (and +design §5.3) both wrote `createUserIdMapping({ supertokensUserId, ... })`. +`supertokens-node@24.0.3` reads exactly `superTokensUserId`; the lowercase +spelling is accepted silently as `undefined` and the mapping is simply never +created. That failure is invisible — no throw, no log — and surfaces only as +the wrong-save bug this task exists to prevent. Verified against +`lib/build/index.d.ts`, called out in a comment at the call site, and pinned by +mutation test (the typo fails 8 tests). + +**The existing-mapping check runs first, before `createUserIdMapping`.** Not in +the plan, and required for correctness rather than tidiness: once a mapping +exists, the core translates ids in *every* response, so on a returning login +`signInUp` hands back the EXTERNAL id. Calling `createUserIdMapping` with it +returns `UNKNOWN_SUPERTOKENS_USER_ID_ERROR` — an error status describing the +fully-correct steady state. Treating that as a failure would break every +login after the first. So `linkExternalUserId` asks `getUserIdMapping` first +and only creates when there is genuinely nothing there. + +**A mapping that points somewhere else fails the login, loudly.** If the core +maps this SuperTokens user to a different `users.id` than `identities` +resolves, the two sources of truth disagree about who is logging in. +Proceeding would serve one player another player's save. Throwing is +recoverable; that is not. + +**An existing identity resolves read-only — `upsertUser` is not called.** Per +the plan's step 2 ordering, but worth recording why it matters: `upsertUser` +would also refresh the username from the profile, so any disagreement between +this module's `deriveUsername` and what `passport` stored would silently +rename every returning player on their first SuperTokens login. The cost is +that `identities.last_login_at` is not bumped on a SuperTokens login. Nothing +reads that column today (`getAllUsersWithSaves` does not select it and +`listIdentities` has no consumer), so this is invisible — but it is a real +gap, and a future release that surfaces "last seen" must add the bump back. + +**`setSupertokensUserId` is a silent no-op on a missing identity row**, not a +throw. It runs immediately *after* `createUserIdMapping`, so throwing would +fail the login while leaving the core-side mapping in place — and the retry +would then trip over that mapping instead, turning a bookkeeping miss into a +permanent lockout. Matches `setRoles`/`setToursCompleted`; documented in +`interface.md`. + +**The ordering test needs a genuinely async fake core, and that was found by +mutation, not by inspection.** With an instantly-resolving fake, deleting the +`await` on `linkExternalUserId` — one of the easiest bugs to introduce here — +still passed the ordering assertion, because the dangling promise won the race +against session creation. Every fake core method now crosses a macrotask +boundary, modelling the network hop the real core is. Both mutations were run +in both directions: with the tick, a dropped `await` fails 5 tests; without +it, it failed none of the ordering ones. The test file says this in a comment +so nobody "simplifies" the tick away. + +**The negative control is part of the suite.** A test that wires the identical +mapping logic *after* session creation (as an `apis` override would) asserts +that the end state is indistinguishable — the mapping does exist — while the +session id is permanently wrong. It exists so that an "is there a mapping at +the end?" assertion can never be mistaken for a test of the ordering. + ### Correction to the design Spec §5.5 called the "SuperTokens `thirdPartyUserId` equals passport's diff --git a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md index 113a970..06153a2 100644 --- a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md +++ b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md @@ -257,8 +257,9 @@ Starts only after v1.7 is confirmed running in production. > > Implementation plan: `docs/superpowers/plans/2026-08-06-v1.8-supertokens.md`. > Operator runbook: `docs/supertokens-rollout-runbook.md`. -> Progress: Tasks 1–2 of 7 built (the `AUTH_MODE` switch; SuperTokens init, -> provider config and conditional mounting). +> Progress: Tasks 1–3 of 7 built (the `AUTH_MODE` switch; SuperTokens init, +> provider config and conditional mounting; the identity mapping and its +> ordering guarantee). ### 5.1 Containers @@ -317,7 +318,23 @@ In the `signInUp` override: 1. Look up `identities` by `(provider, provider_id)`. 2. Resolve the existing `users.id` (or create user + identity for a new player). -3. Call `createUserIdMapping({ supertokensUserId, externalUserId: users.id })`. +3. Call `createUserIdMapping({ superTokensUserId, externalUserId: users.id })`. + +> **Spelling correction (2026-08-06, Task 3).** Note the capital T in +> `superTokensUserId`. This document and the implementation plan both +> originally wrote `supertokensUserId`, which `supertokens-node@24` accepts +> silently as `undefined` — no throw, no log, and no mapping created. Since +> the whole failure mode below is invisible, a typo here would be +> indistinguishable from never having written the step at all. + +Because the core translates user ids in every response once a mapping exists, +a *returning* login receives the external id back from `signInUp`. The +override therefore checks `getUserIdMapping` first and only creates a mapping +when there genuinely is none — treating `UNKNOWN_SUPERTOKENS_USER_ID_ERROR` as +a failure would break every login after the first. A mapping that exists but +points at a *different* `users.id` than `identities` resolves fails the login +loudly, since guessing between two disagreeing sources of truth is how a +player ends up on someone else's save. Afterwards `session.getUserId()` returns e.g. `github:37058311`, so saves, roles, event participation and `SUPER_ADMIN_IDS` all continue to resolve. diff --git a/server/db/driver.pg.js b/server/db/driver.pg.js index c0f3636..63fb251 100644 --- a/server/db/driver.pg.js +++ b/server/db/driver.pg.js @@ -194,6 +194,49 @@ export async function createPgDriver({ url }) { return all('SELECT * FROM identities WHERE user_id = $1 ORDER BY created_at ASC', [userId]); }, + /** + * One login method by its (provider, provider_id) pair - the primary key - + * or `undefined` when that pair has never logged in. + * + * This is the read SuperTokens' signInUp override keys off (v1.8): it maps + * `thirdPartyId`/`thirdPartyUserId` onto exactly this pair, and the + * `user_id` it returns is what gets registered as the external user id. + * Deliberately separate from upsertUser's internal lookup, because the + * override must be able to ask "does this player already exist?" WITHOUT + * the side effect of creating them. + */ + async getIdentity(provider, providerId) { + return one('SELECT * FROM identities WHERE provider = $1 AND provider_id = $2', [provider, providerId]); + }, + + /** + * Records the SuperTokens-internal user id that has been mapped onto this + * identity. Bookkeeping on our side only - the mapping that actually + * governs what `session.getUserId()` returns lives in the SuperTokens + * core, created by `createUserIdMapping`. + * + * Safe to call on every login. The column is UNIQUE, but re-writing a row's + * own existing value is not a conflict with itself, so the re-login path + * needs no guard. A conflict here means two identities were handed the same + * SuperTokens id, which is real corruption and must surface, so it is + * deliberately NOT swallowed. + * + * A missing identity row is a silent no-op, matching setRoles / + * setToursCompleted. Throwing instead would be actively dangerous in the + * one place this is called from: it runs immediately AFTER + * createUserIdMapping, so a throw would fail the login while leaving the + * core-side mapping in place - and the retry would then fail on the + * already-exists mapping instead, locking the account out permanently. + * Callers reach here having just resolved or created the identity, so a + * miss cannot happen without a caller-side bug. + */ + async setSupertokensUserId(provider, providerId, supertokensUserId) { + await run( + 'UPDATE identities SET supertokens_user_id = $1 WHERE provider = $2 AND provider_id = $3', + [supertokensUserId, provider, providerId], + ); + }, + async getSave(userId) { return one('SELECT * FROM saves WHERE user_id = $1', [userId]); }, diff --git a/server/db/driver.sqlite.js b/server/db/driver.sqlite.js index e0ee4ee..fd9a452 100644 --- a/server/db/driver.sqlite.js +++ b/server/db/driver.sqlite.js @@ -187,6 +187,50 @@ export async function createSqliteDriver({ path: dbPath }) { return db.prepare('SELECT * FROM identities WHERE user_id = ? ORDER BY created_at ASC').all(userId); }, + /** + * One login method by its (provider, provider_id) pair - the primary key - + * or `undefined` when that pair has never logged in. + * + * This is the read SuperTokens' signInUp override keys off (v1.8): it maps + * `thirdPartyId`/`thirdPartyUserId` onto exactly this pair, and the + * `user_id` it returns is what gets registered as the external user id. + * Deliberately separate from upsertUser's internal lookup, because the + * override must be able to ask "does this player already exist?" WITHOUT + * the side effect of creating them. + */ + async getIdentity(provider, providerId) { + return db.prepare( + 'SELECT * FROM identities WHERE provider = ? AND provider_id = ?', + ).get(provider, providerId); + }, + + /** + * Records the SuperTokens-internal user id that has been mapped onto this + * identity. Bookkeeping on our side only - the mapping that actually + * governs what `session.getUserId()` returns lives in the SuperTokens + * core, created by `createUserIdMapping`. + * + * Safe to call on every login. The column is UNIQUE, but re-writing a row's + * own existing value is not a conflict with itself, so the re-login path + * needs no guard. A conflict here means two identities were handed the same + * SuperTokens id, which is real corruption and must surface, so it is + * deliberately NOT swallowed. + * + * A missing identity row is a silent no-op, matching setRoles / + * setToursCompleted. Throwing instead would be actively dangerous in the + * one place this is called from: it runs immediately AFTER + * createUserIdMapping, so a throw would fail the login while leaving the + * core-side mapping in place - and the retry would then fail on the + * already-exists mapping instead, locking the account out permanently. + * Callers reach here having just resolved or created the identity, so a + * miss cannot happen without a caller-side bug. + */ + async setSupertokensUserId(provider, providerId, supertokensUserId) { + db.prepare( + 'UPDATE identities SET supertokens_user_id = ? WHERE provider = ? AND provider_id = ?', + ).run(supertokensUserId, provider, providerId); + }, + async getSave(userId) { return db.prepare('SELECT * FROM saves WHERE user_id = ?').get(userId); }, diff --git a/server/db/index.js b/server/db/index.js index 81bb4db..d94d384 100644 --- a/server/db/index.js +++ b/server/db/index.js @@ -21,6 +21,7 @@ export const { setEventStatus, deleteEvent, upsertParticipation, getParticipation, updateParticipationProgress, listParticipation, setLeaderboardOptOut, listLeaderboard, getLatestEventId, seedSeasonalEvents, listIdentities, + getIdentity, setSupertokensUserId, } = driver; export { driver }; diff --git a/server/db/interface.md b/server/db/interface.md index d1098fb..306b65d 100644 --- a/server/db/interface.md +++ b/server/db/interface.md @@ -50,7 +50,8 @@ getOpenMinigameSession, finishMinigameSession, getConfigRow, putConfigRow, getConfigHistory, listEvents, getEvent, getActiveEvent, putEvent, setEventStatus, deleteEvent, upsertParticipation, getParticipation, updateParticipationProgress, listParticipation, setLeaderboardOptOut, -listLeaderboard, getLatestEventId, seedSeasonalEvents, listIdentities +listLeaderboard, getLatestEventId, seedSeasonalEvents, listIdentities, +getIdentity, setSupertokensUserId ``` `tests/db.interface.test.js` asserts this exact list is exported as @@ -81,6 +82,32 @@ these breaks every consumer. field — the user's *primary* identity (earliest `created_at`, ties broken by provider name), not necessarily their only one. +### The two v1.8 identity functions + +- `getIdentity(provider, providerId)` → the `identities` row, or `undefined` + when that pair has never logged in (the same missing-row contract as every + other read — `undefined`, never `null`). Unlike the lookup inside + `upsertUser`, this one has no side effect: SuperTokens' `signInUp` override + needs to ask "does this player already exist?" *before* deciding whether to + create anything. +- `setSupertokensUserId(provider, providerId, supertokensUserId)` → void. + Records which SuperTokens-internal user id has been mapped onto this + identity. Purely our-side bookkeeping; the mapping that governs what + `session.getUserId()` returns lives in the SuperTokens core. + + **Idempotent on re-login.** `identities.supertokens_user_id` is `UNIQUE`, + but a row re-writing its own existing value does not conflict with itself, + so calling this on every login needs no guard and no upsert. A conflict + that *does* fire means two different identities were handed the same + SuperTokens id — genuine corruption — and is deliberately allowed to throw. + + A missing identity row is a silent no-op, matching `setRoles` / + `setToursCompleted`. Deliberate rather than lenient: the sole caller runs + immediately after `createUserIdMapping`, so throwing here would fail the + login while leaving the core-side mapping in place, and the retry would + then fail on the already-exists mapping — a permanent lockout, which is + strictly worse than an unrecorded bookkeeping column. + ## Schema versioning `schema_migrations(version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)` diff --git a/server/supertokens/init.js b/server/supertokens/init.js index a884d7e..6f59aca 100644 --- a/server/supertokens/init.js +++ b/server/supertokens/init.js @@ -22,6 +22,7 @@ import { isSuperTokensEnabled } from '../authMode.js'; import { buildProviders, resolvePublicOrigin } from './providers.js'; +import { buildSignInUpOverride } from './mapping.js'; // SuperTokens' own default API base path. It is also why the runbook widens // the GitHub OAuth registration to /auth: SuperTokens serves its callbacks at @@ -86,7 +87,16 @@ export async function initSuperTokens({ env = process.env, mode } = {}) { websiteBasePath: '/', }, recipeList: [ - ThirdParty.init({ signInUpFeature: { providers } }), + ThirdParty.init({ + signInUpFeature: { providers }, + // The override is on `functions` (the recipe function), NOT `apis`. + // SuperTokens creates the session in the API layer after the recipe + // function returns, so an `apis` override would run too late to get + // the user id mapping in place first - and a session carrying + // SuperTokens' internal id resolves to no save at all. See + // ./mapping.js and design section 5.3. + override: { functions: buildSignInUpOverride({ supertokens }) }, + }), Session.init(), ], }); diff --git a/server/supertokens/mapping.js b/server/supertokens/mapping.js new file mode 100644 index 0000000..d5068d1 --- /dev/null +++ b/server/supertokens/mapping.js @@ -0,0 +1,238 @@ +// The v1.8 identity mapping - the mechanism the whole release hinges on. +// +// SuperTokens mints its own opaque internal user id for every third-party +// login. RackStack's `users.id` is the literal string `provider:providerId` +// (e.g. `github:37058311`) and is the target of three foreign keys, every +// save row, and every value an operator has put in SUPER_ADMIN_IDS. Those two +// ids must be reconciled, and only in one direction: SuperTokens' id is +// mapped ONTO ours. `users.id` never changes. +// +// The reconciliation is `createUserIdMapping`, which lives in the SuperTokens +// CORE, not the SDK. Once a mapping exists, the core rewrites the user id in +// every response it sends - including the response to session creation. That +// is the entire reason ordering is load-bearing: +// +// mapping created -> session created => session.getUserId() === 'github:37058311' +// session created -> mapping created => session.getUserId() === '' +// +// In the second case every route resolves a user id that matches no save, no +// role and no SUPER_ADMIN_IDS entry, so a returning player silently lands on a +// brand-new empty save. There is no error, no log line, and no way back for a +// player who then plays on that empty save. +// +// This is why the override belongs on the RECIPE FUNCTION (`signInUp`) and not +// on the API (`signInUpPOST`): SuperTokens creates the session in the API +// layer, AFTER the recipe function returns. Overriding the API function puts +// our code on the wrong side of the session. See design section 5.3, and +// tests/supertokens.mapping.test.js, which asserts the ordering directly +// rather than asserting the end state (the end state is identical either way). +// +// A second consequence of the same ordering: `createUserIdMapping` refuses to +// map a SuperTokens user that already has data associated with it unless +// `force: true` is passed. Running before session creation means there is no +// such data yet, so no force is needed - and no force SHOULD be used, because +// it would paper over exactly the mismatch this module throws on. + +import { + getIdentity as dbGetIdentity, + upsertUser as dbUpsertUser, + setSupertokensUserId as dbSetSupertokensUserId, +} from '../db/index.js'; + +/** + * The default database dependency set. Injected rather than imported directly + * at the call sites below so the mapping logic can be exercised against fakes + * without standing up a driver, and - more importantly - so the ordering test + * can instrument every call this module makes. + */ +const defaultDb = { + getIdentity: dbGetIdentity, + upsertUser: dbUpsertUser, + setSupertokensUserId: dbSetSupertokensUserId, +}; + +/** + * Best-effort display name for a brand-new player, mirroring what passport + * stores today so a player who signs up through either stack gets the same + * name. + * + * `passport-github2` uses the profile's `login`; `passport-discord` uses + * `username`. Both are present in SuperTokens' `rawUserInfoFromProvider + * .fromUserInfoAPI`, which is the provider's raw user JSON. The fallbacks + * exist because `upsertUser` writes this into a NOT-cosmetic column and a + * blank name would surface as an empty row in the admin list - never because + * a name is expected to be missing. + * + * Only ever used for players who do not exist yet. A returning player's + * username is untouched by this module (see resolveExternalUserId). + */ +export function deriveUsername({ thirdPartyId, thirdPartyUserId, rawUserInfoFromProvider = {}, email }) { + const raw = rawUserInfoFromProvider.fromUserInfoAPI ?? {}; + const candidate = raw.login ?? raw.username ?? raw.global_name ?? raw.name; + if (typeof candidate === 'string' && candidate.trim()) return candidate.trim(); + if (typeof email === 'string' && email.includes('@')) return email.split('@')[0]; + return `${thirdPartyId}-${thirdPartyUserId}`; +} + +/** Avatar URL from the provider's raw user JSON, or null. */ +export function deriveAvatarUrl({ rawUserInfoFromProvider = {} }) { + const raw = rawUserInfoFromProvider.fromUserInfoAPI ?? {}; + if (typeof raw.avatar_url === 'string' && raw.avatar_url) return raw.avatar_url; + // Discord returns a bare avatar hash, and building the CDN URL from it needs + // the user id too. Same shape passport-discord's profile carries. + if (typeof raw.avatar === 'string' && raw.avatar && typeof raw.id === 'string') { + return `https://cdn.discordapp.com/avatars/${raw.id}/${raw.avatar}.png`; + } + return null; +} + +/** + * Resolves the RackStack `users.id` for a third-party login, creating the + * player only if they are genuinely new. + * + * The lookup is by `(thirdPartyId, thirdPartyUserId)`, which is exactly the + * `(provider, provider_id)` pair `identities` is keyed on - that equality is + * the reason an existing passport player is recognised rather than duplicated. + * + * An EXISTING identity resolves to its `user_id` and writes nothing. That is + * deliberate: `upsertUser` would also refresh the username from the profile, + * and if this module's `deriveUsername` ever disagreed with what passport + * stored, every returning player would be silently renamed on their first + * SuperTokens login. Read-only here is the migration-safe choice, and the only + * thing given up is a `last_login_at` bump, which nothing currently reads. + */ +export async function resolveExternalUserId(input, db = defaultDb) { + const { thirdPartyId, thirdPartyUserId } = input; + if (!thirdPartyId || !thirdPartyUserId) { + throw new Error( + `SuperTokens signInUp supplied an incomplete identity (thirdPartyId=${thirdPartyId}, ` + + `thirdPartyUserId=${thirdPartyUserId}); refusing to map it to a user id.`, + ); + } + + const identity = await db.getIdentity(thirdPartyId, thirdPartyUserId); + if (identity) return { externalUserId: identity.user_id, created: false }; + + const user = await db.upsertUser({ + provider: thirdPartyId, + providerId: thirdPartyUserId, + username: deriveUsername(input), + avatarUrl: deriveAvatarUrl(input), + }); + return { externalUserId: user.id, created: true }; +} + +/** + * Registers the SuperTokens-internal user id against our `users.id` in the + * core, and records the linkage on our side. + * + * Idempotent by construction, because it runs on EVERY login, not just the + * first. The existing-mapping check comes first rather than treating + * `createUserIdMapping`'s error statuses as the happy path, because on a + * returning login the core has already translated the id and hands us back the + * EXTERNAL one - at which point `createUserIdMapping` would report + * `UNKNOWN_SUPERTOKENS_USER_ID_ERROR` for what is actually the fully-correct + * steady state. + * + * A mapping that exists but points somewhere else is fatal and throws. That + * means the core believes this login belongs to a different player than + * `identities` does, and continuing would serve one player another player's + * save - the precise outcome this release exists to prevent. Failing the login + * is recoverable; serving the wrong save is not. + */ +export async function linkExternalUserId( + { supertokensUserId, externalUserId, thirdPartyId, thirdPartyUserId }, + { db = defaultDb, supertokens }, +) { + const existing = await supertokens.getUserIdMapping({ + userId: supertokensUserId, userIdType: 'ANY', + }); + + if (existing.status === 'OK') { + if (existing.externalUserId !== externalUserId) { + throw new Error( + `SuperTokens user id mapping conflict for ${thirdPartyId}:${thirdPartyUserId} - ` + + `the core maps it to '${existing.externalUserId}' but identities resolves it to ` + + `'${externalUserId}'. Refusing to issue a session rather than serve the wrong save.`, + ); + } + } else { + const result = await supertokens.createUserIdMapping({ + superTokensUserId: supertokensUserId, + externalUserId, + }); + + // Note the capital T in `superTokensUserId` above. The SDK reads exactly + // that key; a `supertokensUserId` typo is accepted silently as `undefined` + // and the mapping is simply never created - which fails as the invisible + // wrong-save bug, not as an error. + if (result.status !== 'OK') { + if (result.status === 'USER_ID_MAPPING_ALREADY_EXISTS_ERROR') { + // Lost a race with a concurrent login for the same player. Harmless if + // and only if the mapping that won points where ours would have. + const raced = await supertokens.getUserIdMapping({ + userId: supertokensUserId, userIdType: 'ANY', + }); + if (raced.status === 'OK' && raced.externalUserId === externalUserId) { + return externalUserId; + } + } + throw new Error( + `Failed to map SuperTokens user '${supertokensUserId}' onto '${externalUserId}' ` + + `for ${thirdPartyId}:${thirdPartyUserId} (status: ${result.status}). Refusing to ` + + 'issue a session that would resolve to the wrong save.', + ); + } + } + + // Our-side bookkeeping, deliberately last: the core-side mapping is what + // governs the session, and this column only records that it happened. + await db.setSupertokensUserId(thirdPartyId, thirdPartyUserId, supertokensUserId); + return externalUserId; +} + +/** + * Pulls the SuperTokens user id out of a `signInUp` response. + * + * `recipeUserId` is a RecipeUserId wrapper, not a string. On a returning login + * this is already the EXTERNAL id, because the core translates before + * responding - `linkExternalUserId` handles that case rather than this one. + */ +export function readSupertokensUserId(response) { + const fromRecipe = response?.recipeUserId?.getAsString?.(); + if (typeof fromRecipe === 'string' && fromRecipe) return fromRecipe; + const fromUser = response?.user?.id; + if (typeof fromUser === 'string' && fromUser) return fromUser; + throw new Error('SuperTokens signInUp returned no usable user id; cannot create a user id mapping.'); +} + +/** + * The ThirdParty recipe-function override. + * + * Everything below runs inside `signInUp`, i.e. strictly before SuperTokens' + * API layer creates the session. Do not move it to `signInUpPOST`. + */ +export function buildSignInUpOverride({ db = defaultDb, supertokens } = {}) { + return (originalImplementation) => ({ + ...originalImplementation, + + async signInUp(input) { + const response = await originalImplementation.signInUp(input); + // SIGN_IN_UP_NOT_ALLOWED / LINKING_TO_SESSION_USER_FAILED - nothing was + // created, so there is nothing to map and no session will be issued. + if (response.status !== 'OK') return response; + + const supertokensUserId = readSupertokensUserId(response); + const { externalUserId } = await resolveExternalUserId(input, db); + + await linkExternalUserId({ + supertokensUserId, + externalUserId, + thirdPartyId: input.thirdPartyId, + thirdPartyUserId: input.thirdPartyUserId, + }, { db, supertokens }); + + return response; + }, + }); +} diff --git a/tests/db.identities.test.js b/tests/db.identities.test.js index 54e7303..551d010 100644 --- a/tests/db.identities.test.js +++ b/tests/db.identities.test.js @@ -15,6 +15,7 @@ const provisioned = await provisionDatabase(); const dbMod = await import('../server/db.js'); const { driver, upsertUser, getUserById, getSave, putSave, getAllUsersWithSaves, listIdentities, + getIdentity, setSupertokensUserId, } = dbMod; afterAll(async () => { @@ -188,6 +189,83 @@ describe('identities split', () => { expect(await listIdentities('github:atomic-1')).toHaveLength(1); }); + it('getIdentity returns the row for a known pair and undefined for an unknown one', async () => { + await upsertUser({ + provider: 'github', providerId: 'gi-1', username: 'gi1', avatarUrl: null, + }); + + const found = await getIdentity('github', 'gi-1'); + expect(found).toMatchObject({ + provider: 'github', provider_id: 'gi-1', user_id: 'github:gi-1', + }); + + // undefined, not null - interface.md's missing-row contract, and the + // signInUp override branches on it to decide whether to create a user. + expect(await getIdentity('github', 'never-logged-in')).toBeUndefined(); + // The pair is a composite key: a matching provider_id under the WRONG + // provider must miss. If either driver ever dropped one half of the + // WHERE clause, a discord user could resolve to a github player's save. + expect(await getIdentity('discord', 'gi-1')).toBeUndefined(); + }); + + it('getIdentity has no side effect - it never creates the user it fails to find', async () => { + // The whole reason this exists separately from upsertUser's internal + // lookup. Shadow mode (Task 5) runs it against production identities and + // must be safe there. + expect(await getIdentity('github', 'phantom')).toBeUndefined(); + expect(await getUserById('github:phantom')).toBeUndefined(); + expect(await listIdentities('github:phantom')).toHaveLength(0); + }); + + it('setSupertokensUserId records the mapping and tolerates the re-login path', async () => { + await upsertUser({ + provider: 'discord', providerId: 'st-1', username: 'stuser', avatarUrl: null, + }); + expect((await getIdentity('discord', 'st-1')).supertokens_user_id).toBeNull(); + + await setSupertokensUserId('discord', 'st-1', 'st-uuid-aaa'); + expect((await getIdentity('discord', 'st-1')).supertokens_user_id).toBe('st-uuid-aaa'); + + // Re-login: the same value written again to the same row. The column is + // UNIQUE, so this is the call that would blow up on a naive INSERT-based + // implementation - and it happens on EVERY subsequent login, i.e. it + // would break logins for everyone the day after they migrate. + await expect( + setSupertokensUserId('discord', 'st-1', 'st-uuid-aaa'), + ).resolves.not.toThrow(); + expect((await getIdentity('discord', 'st-1')).supertokens_user_id).toBe('st-uuid-aaa'); + expect(await listIdentities('discord:st-1')).toHaveLength(1); + }); + + it('setSupertokensUserId is a no-op for an unknown identity rather than throwing', async () => { + // Matches setRoles/setToursCompleted. Documented in interface.md: the + // caller runs this straight after createUserIdMapping, so a throw here + // would fail the login while leaving the core-side mapping behind, and + // the retry would then trip over that mapping instead. + await expect( + setSupertokensUserId('github', 'no-such-identity', 'st-uuid-zzz'), + ).resolves.not.toThrow(); + expect(await getIdentity('github', 'no-such-identity')).toBeUndefined(); + }); + + it('refuses to hand the same supertokens id to two different identities', async () => { + // Not leniency-by-omission: the UNIQUE constraint is what would catch a + // core that recycled an internal id across two players, and swallowing + // it here would mean two identities silently sharing one session subject. + await upsertUser({ + provider: 'github', providerId: 'dup-a', username: 'dupa', avatarUrl: null, + }); + await upsertUser({ + provider: 'github', providerId: 'dup-b', username: 'dupb', avatarUrl: null, + }); + + await setSupertokensUserId('github', 'dup-a', 'st-uuid-shared'); + await expect( + setSupertokensUserId('github', 'dup-b', 'st-uuid-shared'), + ).rejects.toThrow(); + expect((await getIdentity('github', 'dup-b')).supertokens_user_id).toBeNull(); + }); + it('migrates a pre-split SQLite database without losing saves', async () => { // Build a database in the OLD shape, then let applySchema() upgrade it. // Postgres-only test runs use this too - it's a pure SQLite in-memory diff --git a/tests/db.interface.test.js b/tests/db.interface.test.js index 2aa2f40..1c7cbe7 100644 --- a/tests/db.interface.test.js +++ b/tests/db.interface.test.js @@ -21,6 +21,7 @@ const INTERFACE = [ 'setEventStatus', 'deleteEvent', 'upsertParticipation', 'getParticipation', 'updateParticipationProgress', 'listParticipation', 'setLeaderboardOptOut', 'listLeaderboard', 'getLatestEventId', 'seedSeasonalEvents', 'listIdentities', + 'getIdentity', 'setSupertokensUserId', ]; describe('db facade', () => { diff --git a/tests/supertokens.mapping.test.js b/tests/supertokens.mapping.test.js new file mode 100644 index 0000000..0797685 --- /dev/null +++ b/tests/supertokens.mapping.test.js @@ -0,0 +1,373 @@ +// The test the v1.8 release hinges on. +// +// What has to be true is not "a user id mapping exists once login finishes" - +// that is true whether the mapping was created before or after the session, and +// it is the wrong thing either way. What has to be true is that the mapping is +// created BEFORE the session, because the SuperTokens core rewrites user ids in +// its responses using the mapping, so a session created first carries +// SuperTokens' internal id forever and resolves to no save at all. +// +// So this file does not test against the real SDK's end state. It models the +// core's actual behaviour - a mapping table, and a session endpoint that +// translates through it at the moment it is called - and then asserts on the +// observed call order. The final test is a negative control: it wires the same +// mapping logic the WRONG way round (after session creation, as an `apis` +// override would) and proves that the assertions in this file fail for it. An +// ordering assertion that passes for both orderings is not an ordering +// assertion, and this file would be worthless without that control. + +import { + describe, it, expect, afterAll, beforeEach, +} from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { provisionDatabase } from './helpers/backend.js'; + +// Provision before importing the facade - see tests/db.identities.test.js for +// why the import below must be dynamic. +const provisioned = await provisionDatabase(); + +const dbMod = await import('../server/db.js'); +const { + driver, upsertUser, getSave, putSave, getUserById, listIdentities, getIdentity, +} = dbMod; + +const { + buildSignInUpOverride, resolveExternalUserId, linkExternalUserId, + deriveUsername, deriveAvatarUrl, readSupertokensUserId, +} = await import('../server/supertokens/mapping.js'); + +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + +/** + * A stand-in for the SuperTokens core. + * + * The one behaviour that matters is modelled exactly: `createNewSession` + * resolves the id it is handed THROUGH the mapping table, at call time. If no + * mapping exists yet, the session keeps the internal id - which is precisely + * the production failure mode, reproduced rather than described. + */ +function createFakeCore() { + const mappings = new Map(); // supertokens id -> external id + const knownUsers = new Set(); // supertokens ids the core has minted + const calls = []; + + // The real core is a network hop. Every call below crosses a macrotask + // boundary to model that, and it is load-bearing rather than decorative: an + // instantly-resolving fake lets a fire-and-forget `linkExternalUserId(...)` + // - a dropped `await`, one of the easiest bugs to introduce here - still win + // the race against session creation, so the ordering assertions would pass + // for code that is only accidentally correct and would fail in production + // the moment the core took longer than zero milliseconds to answer. Verified + // by mutation: with this tick in place, deleting the `await` in + // buildSignInUpOverride fails the ordering test; without it, it does not. + const hop = () => new Promise((resolve) => { setImmediate(resolve); }); + + return { + calls, + mappings, + registerUser(id) { knownUsers.add(id); }, + externalFor(id) { return mappings.get(id); }, + + async createUserIdMapping({ superTokensUserId, externalUserId }) { + await hop(); + calls.push({ op: 'createUserIdMapping', superTokensUserId, externalUserId }); + // Models the SDK reading the capital-T key: a `supertokensUserId` typo + // arrives here as undefined rather than as an error. + if (!superTokensUserId || !knownUsers.has(superTokensUserId)) { + return { status: 'UNKNOWN_SUPERTOKENS_USER_ID_ERROR' }; + } + if (mappings.has(superTokensUserId)) { + return { + status: 'USER_ID_MAPPING_ALREADY_EXISTS_ERROR', + doesSuperTokensUserIdExist: true, + doesExternalUserIdExist: true, + }; + } + mappings.set(superTokensUserId, externalUserId); + return { status: 'OK' }; + }, + + async getUserIdMapping({ userId }) { + await hop(); + calls.push({ op: 'getUserIdMapping', userId }); + if (mappings.has(userId)) { + return { status: 'OK', superTokensUserId: userId, externalUserId: mappings.get(userId) }; + } + for (const [st, ext] of mappings) { + if (ext === userId) return { status: 'OK', superTokensUserId: st, externalUserId: ext }; + } + return { status: 'UNKNOWN_MAPPING_ERROR' }; + }, + + // The core's session endpoint. Translation happens HERE, at call time - + // which is what makes the ordering observable. + async createNewSession(recipeUserId) { + await hop(); + calls.push({ op: 'createNewSession', recipeUserId }); + const userId = mappings.get(recipeUserId) ?? recipeUserId; + return { getUserId: () => userId }; + }, + }; +} + +/** + * A stand-in for the built-in ThirdParty recipe implementation. Mints a stable + * internal id per (provider, providerId), and - like the real core - reports + * the EXTERNAL id back once a mapping exists, which is what makes the + * returning-login path different from the first one. + */ +function createFakeRecipe(core) { + const minted = new Map(); + return { + async signInUp(input) { + const key = `${input.thirdPartyId}|${input.thirdPartyUserId}`; + let stId = minted.get(key); + const createdNewRecipeUser = !stId; + if (!stId) { + stId = `st-${randomUUID()}`; + minted.set(key, stId); + core.registerUser(stId); + } + const visible = core.externalFor(stId) ?? stId; + return { + status: 'OK', + createdNewRecipeUser, + recipeUserId: { getAsString: () => visible }, + user: { id: visible }, + }; + }, + }; +} + +/** + * Mimics SuperTokens' real API layer: call the (overridden) recipe function, + * then create the session from what it returned. The override under test is + * installed on the recipe function, so it runs strictly inside step one. + */ +function createHarness() { + const core = createFakeCore(); + const original = createFakeRecipe(core); + const overridden = buildSignInUpOverride({ supertokens: core })(original); + + async function signInUpPOST(input) { + const response = await overridden.signInUp(input); + const session = await core.createNewSession(response.recipeUserId.getAsString()); + return { response, session }; + } + + return { core, original, overridden, signInUpPOST }; +} + +function loginInput(thirdPartyId, thirdPartyUserId, extra = {}) { + return { + thirdPartyId, + thirdPartyUserId, + email: `${thirdPartyUserId}@example.com`, + isVerified: true, + oAuthTokens: {}, + rawUserInfoFromProvider: { fromUserInfoAPI: {} }, + tenantId: 'public', + userContext: {}, + ...extra, + }; +} + +const opsOf = (core) => core.calls.map((c) => c.op); + +describe('supertokens identity mapping - ordering', () => { + it('creates the user id mapping strictly before the session is created', async () => { + const { core, signInUpPOST } = createHarness(); + await signInUpPOST(loginInput('github', 'order-1')); + + const ops = opsOf(core); + const mappedAt = ops.indexOf('createUserIdMapping'); + const sessionAt = ops.indexOf('createNewSession'); + + expect(mappedAt, 'no user id mapping was ever created').toBeGreaterThan(-1); + expect(sessionAt, 'no session was ever created').toBeGreaterThan(-1); + expect(mappedAt).toBeLessThan(sessionAt); + }); + + it('issues a session carrying our users.id, not SuperTokens internal id', async () => { + // The consequence of the ordering, asserted on the value a route handler + // would actually read. + const { session } = await createHarness().signInUpPOST(loginInput('github', 'order-2')); + expect(session.getUserId()).toBe('github:order-2'); + expect(session.getUserId()).not.toMatch(/^st-/); + }); + + it('NEGATIVE CONTROL: the same logic wired after session creation fails these assertions', async () => { + // Proves the two assertions above have teeth. This wires the mapping the + // way an `apis`/signInUpPOST override would - identical logic, identical + // end state, one step too late - and shows both assertions catch it. If + // this test ever starts passing the ordering assertions, they have gone + // vacuous and the release's central guarantee is untested. + const core = createFakeCore(); + const original = createFakeRecipe(core); + + const response = await original.signInUp(loginInput('github', 'order-3')); + // Session FIRST - the mistake being modelled. + const session = await core.createNewSession(response.recipeUserId.getAsString()); + // ...then exactly the same mapping work, afterwards. + const supertokensUserId = readSupertokensUserId(response); + const { externalUserId } = await resolveExternalUserId(loginInput('github', 'order-3')); + await linkExternalUserId( + { + supertokensUserId, externalUserId, thirdPartyId: 'github', thirdPartyUserId: 'order-3', + }, + { supertokens: core }, + ); + + const ops = opsOf(core); + expect(ops.indexOf('createUserIdMapping')).toBeGreaterThan(ops.indexOf('createNewSession')); + + // The mapping exists - an "is there a mapping at the end?" assertion would + // pass right here, which is exactly why this file does not use one. + expect(core.mappings.get(supertokensUserId)).toBe('github:order-3'); + + // And yet the session is wrong, permanently. + expect(session.getUserId()).toBe(supertokensUserId); + expect(session.getUserId()).not.toBe('github:order-3'); + }); +}); + +describe('supertokens identity mapping - identity outcomes', () => { + it('resolves an existing passport player to their existing save', async () => { + // The whole point of the release. Asserted on save CONTENTS, not just on + // the id matching, because a matching id with an empty save would be the + // same disaster wearing a disguise. + await upsertUser({ + provider: 'github', providerId: 'veteran', username: 'veteran', avatarUrl: null, + }); + await putSave('github:veteran', { wafers: 9001, marker: 'pre-existing' }, 1234); + + const { session } = await createHarness().signInUpPOST(loginInput('github', 'veteran')); + + expect(session.getUserId()).toBe('github:veteran'); + const save = await getSave(session.getUserId()); + expect(JSON.parse(save.data)).toEqual({ wafers: 9001, marker: 'pre-existing' }); + // No second account was conjured alongside the real one. + expect(await listIdentities('github:veteran')).toHaveLength(1); + }); + + it('does not create a new user row for an existing player', async () => { + await upsertUser({ + provider: 'discord', providerId: 'existing-1', username: 'exist1', avatarUrl: null, + }); + const before = await getUserById('discord:existing-1'); + + await createHarness().signInUpPOST(loginInput('discord', 'existing-1', { + rawUserInfoFromProvider: { fromUserInfoAPI: { username: 'a-totally-different-name' } }, + })); + + const after = await getUserById('discord:existing-1'); + expect(after.id).toBe(before.id); + expect(after.created_at).toBe(before.created_at); + // A returning player is never renamed from the SuperTokens-side profile - + // resolveExternalUserId writes nothing at all on this path. + expect(after.username).toBe('exist1'); + }); + + it('creates user, identity and mapping for a brand-new player', async () => { + const { core, session } = await (async () => { + const h = createHarness(); + const r = await h.signInUpPOST(loginInput('github', 'fresh-1', { + rawUserInfoFromProvider: { fromUserInfoAPI: { login: 'freshuser', avatar_url: 'https://x/y.png' } }, + })); + return { core: h.core, ...r }; + })(); + + expect(session.getUserId()).toBe('github:fresh-1'); + const user = await getUserById('github:fresh-1'); + expect(user).toBeDefined(); + expect(user.username).toBe('freshuser'); + expect(user.avatar_url).toBe('https://x/y.png'); + + const identity = await getIdentity('github', 'fresh-1'); + expect(identity.user_id).toBe('github:fresh-1'); + // The mapping the core holds points at OUR id, in that direction only. + expect([...core.mappings.values()]).toContain('github:fresh-1'); + expect(identity.supertokens_user_id).toBe([...core.mappings.keys()][0]); + }); + + it('is idempotent across repeated logins - no duplicate identity, no unique violation', async () => { + const h = createHarness(); + await h.signInUpPOST(loginInput('github', 'repeat-1')); + await h.signInUpPOST(loginInput('github', 'repeat-1')); + const third = await h.signInUpPOST(loginInput('github', 'repeat-1')); + + expect(third.session.getUserId()).toBe('github:repeat-1'); + expect(await listIdentities('github:repeat-1')).toHaveLength(1); + // Exactly one mapping was ever created; the later logins recognised the + // steady state instead of trying to re-create it. + const created = h.core.calls.filter((c) => c.op === 'createUserIdMapping'); + expect(created).toHaveLength(1); + }); + + it('refuses to issue a session when the core maps this login to a different player', async () => { + // The one case where failing the login is the correct outcome: the core + // and identities disagree about who this is, and guessing means serving + // someone else's save. + const h = createHarness(); + await h.signInUpPOST(loginInput('github', 'conflict-1')); + const stId = [...h.core.mappings.keys()].find((k) => h.core.mappings.get(k) === 'github:conflict-1'); + + // Corrupt the core's view: it now believes this login is somebody else. + h.core.mappings.set(stId, 'github:someone-else'); + + await expect(h.signInUpPOST(loginInput('github', 'conflict-1'))).rejects.toThrow(/mapping conflict/i); + }); + + it('propagates a non-OK signInUp untouched, mapping nothing', async () => { + const core = createFakeCore(); + const original = { + async signInUp() { return { status: 'SIGN_IN_UP_NOT_ALLOWED', reason: 'nope' }; }, + }; + const overridden = buildSignInUpOverride({ supertokens: core })(original); + + const res = await overridden.signInUp(loginInput('github', 'denied-1')); + expect(res.status).toBe('SIGN_IN_UP_NOT_ALLOWED'); + expect(core.calls).toHaveLength(0); + expect(await getUserById('github:denied-1')).toBeUndefined(); + }); + + it('refuses an incomplete identity rather than inventing a user id', async () => { + await expect(resolveExternalUserId({ thirdPartyId: 'github', thirdPartyUserId: '' })) + .rejects.toThrow(/incomplete identity/i); + }); +}); + +describe('supertokens profile derivation', () => { + beforeEach(() => {}); + + it('uses github login and discord username, matching what passport stores', () => { + expect(deriveUsername(loginInput('github', '1', { + rawUserInfoFromProvider: { fromUserInfoAPI: { login: 'octocat' } }, + }))).toBe('octocat'); + + expect(deriveUsername(loginInput('discord', '2', { + rawUserInfoFromProvider: { fromUserInfoAPI: { username: 'discorduser' } }, + }))).toBe('discorduser'); + }); + + it('falls back through email local-part to a provider-qualified id', () => { + expect(deriveUsername({ + thirdPartyId: 'github', thirdPartyUserId: '3', email: 'someone@example.com', rawUserInfoFromProvider: {}, + })).toBe('someone'); + + expect(deriveUsername({ + thirdPartyId: 'github', thirdPartyUserId: '4', rawUserInfoFromProvider: {}, + })).toBe('github-4'); + }); + + it('builds the discord CDN url from a bare avatar hash', () => { + expect(deriveAvatarUrl({ + rawUserInfoFromProvider: { fromUserInfoAPI: { id: '123', avatar: 'abc' } }, + })).toBe('https://cdn.discordapp.com/avatars/123/abc.png'); + + expect(deriveAvatarUrl({ rawUserInfoFromProvider: { fromUserInfoAPI: {} } })).toBeNull(); + }); +}); From 435d09fa437fb342235188b21b2bd51e7739bf8b Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 18:47:27 -0400 Subject: [PATCH 06/14] v1.8 Task 4: the authentication chain across all three modes requireAuth becomes a chain: SuperTokens session first, then the legacy JWT cookie, then 401. Both branches populate an identical `{ sub, username, avatarUrl }`, so no route handler changes - req.user.sub is the only identity field they read. Three decisions the plan did not anticipate: - The chain branches on "is SuperTokens initialised in this process", not on AUTH_MODE. requireAuth is module-level middleware shared by every route while the mode is per-buildApp, so reading the mode there would mean guessing which app a request belongs to. - The login routes moved out of api.js into authRoutes.js as a factory. They are the only routes whose registration depends on the mode, and api.js exports a singleton router - gating them in place would leak one app's routes into another built in the same process. - A SuperTokens session whose subject matches no users row is rejected rather than trusted. It would mean the mapping resolved to something users has never heard of, which is the silent empty-save outcome by another route. The JWT branch runs in every mode including `supertokens`. That is what makes the rollback real: legacy cookies keep working for their full 90-day expiry through a transition in either direction. `supertokens` mode stops issuing them; it does not start rejecting the ones already out there. Logout is registered in every mode and clears both stacks. A logout that only clears half of a dual-stack session shows the user a logged-out UI while leaving them authenticated. requireRole confirmed unchanged by reading it - it re-derives roles from req.user.sub on every request - and now asserted through both stacks. Found and recorded, not fixed: the client has no SuperTokens frontend SDK, so it cannot refresh an expired access token. Harmless in `dual` (the legacy cookie still authenticates), but a supertokens-only cutover needs frontend refresh handling first. Carried into the runbook in Task 7. Mutation-verified both ways: rethrowing instead of falling through fails the two fall-through tests; reversing the chain order fails the ordering test. 572 tests green on SQLite, 595 on Postgres, 39 smoke assertions across all six e2e suites. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-06-v1.8-supertokens.md | 66 +++- .../2026-08-01-postgres-supertokens-design.md | 9 +- server/app.js | 5 + server/auth.js | 87 +++++- server/routes/api.js | 40 +-- server/routes/authRoutes.js | 85 +++++ server/supertokens/init.js | 33 ++ tests/supertokens.middleware.test.js | 294 ++++++++++++++++++ 8 files changed, 572 insertions(+), 47 deletions(-) create mode 100644 server/routes/authRoutes.js create mode 100644 tests/supertokens.middleware.test.js diff --git a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md index 4bb9c6c..bb064b5 100644 --- a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md +++ b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md @@ -185,21 +185,21 @@ Both new functions, with the `undefined`-on-miss contract and the re-login idemp - Consumes: Tasks 1–3. - Produces: `requireAuth` resolving `req.user = { sub, username, avatarUrl }` from either stack. -- [ ] **Step 1: Turn `requireAuth` into a chain** +- [x] **Step 1: Turn `requireAuth` into a chain** Try the SuperTokens session first (only when enabled), fall back to the legacy JWT cookie, then 401. Both paths populate the identical `req.user` shape. `requireRole` already re-derives roles from `req.user.sub` on every request and needs no change — confirm that by reading it, and say so. The SuperTokens attempt must not throw past the fallback: a malformed or expired SuperTokens session in `dual` mode has to fall through to the JWT check, not 500. This is the failure mode that would take down logins for users mid-rollout. -- [ ] **Step 2: Gate the passport routes by mode** +- [x] **Step 2: Gate the passport routes by mode** In `supertokens` mode the `/auth/discord` and `/auth/github` passport routes are not registered. `/auth/logout` must clear **both** a legacy cookie and a SuperTokens session, in every mode — a logout that only half-works is worse than one that fails loudly. -- [ ] **Step 3: Test all three modes** +- [x] **Step 3: Test all three modes** For each of `passport`, `dual`, `supertokens`: an authenticated request reaches a protected route with the right `sub`; an unauthenticated one gets 401. In `dual`, specifically: a legacy JWT cookie issued *before* the switch still authenticates. That is the no-forced-logout guarantee, and it is the one users would notice. -- [ ] **Step 4: Prove passport mode is unchanged** +- [x] **Step 4: Prove passport mode is unchanged** The full pre-existing suite, untouched, green in `passport` mode. @@ -397,6 +397,64 @@ that the end state is indistinguishable — the mapping does exist — while the session id is permanently wrong. It exists so that an "is there a mapping at the end?" assertion can never be mistaken for a test of the ordering. +### Task 4 + +**`requireAuth` branches on "is SuperTokens initialised in this process", not +on `AUTH_MODE`.** Not what the plan implied, and forced by a real constraint: +`requireAuth` is module-level middleware shared by every route, while the mode +is resolved per `buildApp()` call, so reading the mode inside the middleware +would mean guessing which app a request belongs to. Asking the init module +whether it has run is both simpler and strictly safer — if SuperTokens is not +initialised, calling into its SDK would throw, and that is precisely the +condition being tested. `isSuperTokensReady()` was added to `init.js` for this. + +**The JWT branch runs in every mode, including `supertokens`.** This is what +makes the documented rollback real rather than nominal: legacy cookies keep +working for their full 90-day expiry through a transition in either direction. +`supertokens` mode stops *issuing* legacy cookies; it does not start rejecting +the ones already in the wild. Asserted for all three modes. + +**The login routes moved to `server/routes/authRoutes.js`, a router factory.** +Not in the plan's file list, which had them staying in `api.js`. They are the +only routes whose *registration* depends on the mode, and `api.js` exports a +module-level singleton router — so gating them in place would have leaked one +app's routes into another app built in the same process. `createAuthRouter({ +mode })` is built per `buildApp()`. No handler body changed, and `api.js` lost +its `passport` import entirely. + +**`requireRole` confirmed unchanged, as the plan asked.** Read directly: it +takes `req.user.sub` and re-derives roles from the database and +`SUPER_ADMIN_IDS` on every request, caching nothing on `req.user`. Since both +branches of the chain populate an identical `sub`, it cannot be fooled by which +stack authenticated. Now asserted through both stacks rather than left as a +claim. + +**A SuperTokens session whose subject matches no `users` row is rejected.** Not +in the plan. It would mean the id mapping resolved to something `users` has +never heard of, and treating it as authenticated would hand a request context a +`sub` matching no save, no role and no `SUPER_ADMIN_IDS` entry — the silent +empty-save outcome, arrived at by a different route than Task 3's. + +**`requireAuth` is now async and catches its own errors**, for the same Express +4 reason already documented on `requireRole`: a rejected async middleware does +not reach the error handler. + +**Mutation-verified in both directions.** Rethrowing instead of falling through +fails exactly the two fall-through tests; reversing the chain order fails +exactly the ordering test. Neither mutation was caught by luck elsewhere. + +> **Gap to resolve before a `supertokens`-only cutover (not blocking `dual`).** +> The client does not use the SuperTokens frontend SDK — it has no interceptor +> to refresh an expired access token. Per the plan, an expired or malformed +> SuperTokens session falls through to the JWT cookie rather than surfacing +> `TRY_REFRESH_TOKEN`. In `dual` mode that is harmless and correct: the legacy +> cookie is still there and still valid, so the user stays logged in. In +> `supertokens`-only mode, once the legacy cookie has expired, there is nothing +> to fall through *to*, and the user would be silently logged out when the +> SuperTokens access token expires. Cutting over to `dual` is unaffected; +> cutting over to `supertokens` needs frontend refresh handling first. Recorded +> here and carried into the runbook in Task 7. + ### Correction to the design Spec §5.5 called the "SuperTokens `thirdPartyUserId` equals passport's diff --git a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md index 06153a2..963e30a 100644 --- a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md +++ b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md @@ -257,9 +257,14 @@ Starts only after v1.7 is confirmed running in production. > > Implementation plan: `docs/superpowers/plans/2026-08-06-v1.8-supertokens.md`. > Operator runbook: `docs/supertokens-rollout-runbook.md`. -> Progress: Tasks 1–3 of 7 built (the `AUTH_MODE` switch; SuperTokens init, +> Progress: Tasks 1–4 of 7 built (the `AUTH_MODE` switch; SuperTokens init, > provider config and conditional mounting; the identity mapping and its -> ordering guarantee). +> ordering guarantee; the authentication chain across all three modes). +> +> One gap found in Task 4 and not yet closed: the client does not use the +> SuperTokens frontend SDK, so it cannot refresh an expired access token. +> This does not affect `dual` (the legacy cookie still authenticates), but a +> `supertokens`-only cutover needs frontend refresh handling first. ### 5.1 Containers diff --git a/server/app.js b/server/app.js index ef7f961..c8b4a90 100644 --- a/server/app.js +++ b/server/app.js @@ -7,6 +7,7 @@ import { configurePassport } from './auth.js'; import { resolveAuthMode, isSuperTokensEnabled } from './authMode.js'; import { initSuperTokens } from './supertokens/init.js'; import apiRouter from './routes/api.js'; +import { createAuthRouter } from './routes/authRoutes.js'; import './db.js'; // ensures tables exist on boot const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -41,6 +42,10 @@ export async function buildApp({ env = process.env } = {}) { app.use(middleware()); } + // Built per app rather than imported as a singleton: these are the only + // routes whose registration depends on the mode, so two apps built in the + // same process with different modes must not share them. + app.use('/', createAuthRouter({ mode })); app.use('/', apiRouter); if (isSuperTokensEnabled(mode)) { diff --git a/server/auth.js b/server/auth.js index 1d6ebbd..4f19201 100644 --- a/server/auth.js +++ b/server/auth.js @@ -2,7 +2,8 @@ import passport from 'passport'; import { Strategy as DiscordStrategy } from 'passport-discord'; import { Strategy as GitHubStrategy } from 'passport-github2'; import jwt from 'jsonwebtoken'; -import { upsertUser, getRoles } from './db.js'; +import { upsertUser, getRoles, getUserById } from './db.js'; +import { isSuperTokensReady, loadSessionRecipe } from './supertokens/init.js'; const JWT_SECRET = process.env.JWT_SECRET; if (!JWT_SECRET) { @@ -119,13 +120,85 @@ export function issueToken(user) { ); } -export function requireAuth(req, res, next) { - const token = req.cookies && req.cookies[COOKIE_NAME]; - if (!token) return res.status(401).json({ error: 'not authenticated' }); +/** + * Resolves a SuperTokens session into our `req.user` shape, or null. + * + * Returns null - never throws - for every "no usable session" case, including + * a malformed, expired or revoked one. That is deliberate and is the single + * most important property of this function during a rollout: in `dual` mode a + * user can be carrying a stale SuperTokens session AND a perfectly good legacy + * JWT cookie, and letting the SuperTokens failure escape would 500 the request + * instead of falling through to the cookie that would have worked. This is the + * failure mode that would take logins down mid-migration. + * + * `session.getUserId()` returns our `users.id` rather than SuperTokens' + * internal id, because the user id mapping was created before the session was + * issued - see server/supertokens/mapping.js. The username and avatar are not + * in the session (the legacy JWT carries them in its payload), so they come + * from the database. + */ +async function userFromSuperTokens(req, res) { + if (!isSuperTokensReady()) return null; + try { + const Session = await loadSessionRecipe(); + if (!Session) return null; + const session = await Session.getSession(req, res, { sessionRequired: false }); + if (!session) return null; + + const sub = session.getUserId(); + if (!sub) return null; + + // A session for a user id with no row is not an authenticated user. It + // would mean the mapping resolved to something `users` has never heard of, + // and treating it as valid would hand out a request context whose `sub` + // matches no save, no role and no SUPER_ADMIN_IDS entry. + const user = await getUserById(sub); + if (!user) return null; + + return { sub, username: user.username, avatarUrl: user.avatar_url }; + } catch (e) { + return null; + } +} + +/** + * The v1.8 authentication chain: SuperTokens session first, then the legacy + * JWT cookie, then 401. + * + * Both paths populate the identical `req.user = { sub, username, avatarUrl }`, + * which is why no route handler changes in this release - `req.user.sub` is + * the only identity field the handlers read, and `requireRole` re-derives + * roles from it on every request (see requireRole above: it takes + * `req.user.sub` and goes to the database and env, so it needs no change and + * cannot be fooled by whichever stack authenticated the request). + * + * The JWT branch runs in EVERY mode, including `supertokens`. That is what + * makes the documented rollback real: legacy cookies stay valid for their full + * 90-day expiry through every transition in both directions, so nobody is + * forced to log in again by a mode change. `supertokens` mode stops *issuing* + * legacy cookies (the passport routes are not registered); it does not start + * rejecting the ones already in the wild. + * + * Async since v1.8. Express 4 does not route an async middleware's rejection + * to the error handler, so like requireRole this catches its own. + */ +export async function requireAuth(req, res, next) { try { - req.user = jwt.verify(token, JWT_SECRET); - next(); + const stUser = await userFromSuperTokens(req, res); + if (stUser) { + req.user = stUser; + return next(); + } + + const token = req.cookies && req.cookies[COOKIE_NAME]; + if (!token) return res.status(401).json({ error: 'not authenticated' }); + try { + req.user = jwt.verify(token, JWT_SECRET); + } catch (e) { + return res.status(401).json({ error: 'invalid or expired token' }); + } + return next(); } catch (e) { - return res.status(401).json({ error: 'invalid or expired token' }); + return next(e); } } diff --git a/server/routes/api.js b/server/routes/api.js index 95abc91..c7a095c 100644 --- a/server/routes/api.js +++ b/server/routes/api.js @@ -1,11 +1,9 @@ import express from 'express'; -import passport from 'passport'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { - requireAuth, requireRole, issueToken, COOKIE_NAME, - isOwner, getEffectiveRoles, + requireAuth, requireRole, isOwner, getEffectiveRoles, } from '../auth.js'; import { getUserById, getAllUsersWithSaves, getRoles, setRoles, setUsername, @@ -34,13 +32,6 @@ import { TOUR_IDS, ONBOARDING_TOUR_ID, isValidTourId } from '../../shared/tours. const __dirname = path.dirname(fileURLToPath(import.meta.url)); const router = express.Router(); -const COOKIE_OPTS = { - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - maxAge: 90 * 24 * 3600 * 1000, -}; - const MINIGAMES = ['rush', 'debug', 'match', 'balance']; // Event ids are coordinator-authored slugs (matches the seeded seasonal @@ -51,30 +42,11 @@ function isValidEventSlug(id) { return typeof id === 'string' && id.length >= 3 && id.length <= 60 && EVENT_SLUG_RE.test(id); } -function finishLogin(req, res) { - const token = issueToken(req.user); - res.cookie(COOKIE_NAME, token, COOKIE_OPTS); - res.redirect('/'); -} - -router.get('/auth/discord', passport.authenticate('discord', { session: false })); -router.get( - '/auth/discord/callback', - passport.authenticate('discord', { session: false, failureRedirect: '/?authError=discord' }), - finishLogin, -); - -router.get('/auth/github', passport.authenticate('github', { session: false })); -router.get( - '/auth/github/callback', - passport.authenticate('github', { session: false, failureRedirect: '/?authError=github' }), - finishLogin, -); - -router.post('/auth/logout', (req, res) => { - res.clearCookie(COOKIE_NAME); - res.json({ ok: true }); -}); +// The login routes used to live here. They moved to ./authRoutes.js in v1.8, +// because they are the only routes whose registration depends on AUTH_MODE - +// in `supertokens` mode the passport ones must not exist at all. Everything +// below is mode-agnostic: it sits behind requireAuth, which resolves req.user +// from whichever stack authenticated the request. router.get('/api/me', requireAuth, async (req, res, next) => { try { diff --git a/server/routes/authRoutes.js b/server/routes/authRoutes.js new file mode 100644 index 0000000..e3e1386 --- /dev/null +++ b/server/routes/authRoutes.js @@ -0,0 +1,85 @@ +// The login routes, split out of api.js in v1.8 because they are the only +// routes in the codebase whose *registration* depends on AUTH_MODE. +// +// Everything else in api.js is mode-agnostic: it sits behind `requireAuth`, +// which resolves `req.user` from whichever stack authenticated the request. +// These four are different - they ARE the passport stack - so in +// `supertokens` mode they must not exist at all rather than exist and fail. +// +// A router factory rather than a module-level router, because the mode is +// resolved per buildApp() call: a test (and, in principle, a process running +// more than one app) must be able to build a `passport` app and a +// `supertokens` app without the first one's routes leaking into the second. + +import express from 'express'; +import passport from 'passport'; +import { issueToken, COOKIE_NAME } from '../auth.js'; +import { isPassportEnabled } from '../authMode.js'; +import { isSuperTokensReady, loadSessionRecipe } from '../supertokens/init.js'; + +const COOKIE_OPTS = { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 90 * 24 * 3600 * 1000, +}; + +function finishLogin(req, res) { + const token = issueToken(req.user); + res.cookie(COOKIE_NAME, token, COOKIE_OPTS); + res.redirect('/'); +} + +/** + * Builds the auth routes for one mode. + * + * `/auth/logout` is registered in EVERY mode and clears both stacks. A logout + * that only clears half of a dual-stack session is worse than one that fails + * outright: the user sees the logged-out UI, believes they are logged out, and + * is still authenticated on the next request. On a shared machine that is a + * genuine account-exposure bug, not a cosmetic one. + */ +export function createAuthRouter({ mode }) { + const router = express.Router(); + + if (isPassportEnabled(mode)) { + router.get('/auth/discord', passport.authenticate('discord', { session: false })); + router.get( + '/auth/discord/callback', + passport.authenticate('discord', { session: false, failureRedirect: '/?authError=discord' }), + finishLogin, + ); + + router.get('/auth/github', passport.authenticate('github', { session: false })); + router.get( + '/auth/github/callback', + passport.authenticate('github', { session: false, failureRedirect: '/?authError=github' }), + finishLogin, + ); + } + + router.post('/auth/logout', async (req, res) => { + // Always clear the legacy cookie, in every mode - a user in `supertokens` + // mode can still be carrying one from before the cutover, and that cookie + // is exactly what requireAuth's fallback branch would accept. + res.clearCookie(COOKIE_NAME); + + if (isSuperTokensReady()) { + try { + const Session = await loadSessionRecipe(); + const session = await Session.getSession(req, res, { sessionRequired: false }); + if (session) await session.revokeSession(); + } catch (e) { + // Best-effort by design. The legacy cookie is already cleared above, + // and a SuperTokens session that cannot even be read is not one this + // request can revoke. Failing the whole logout here would leave the + // user MORE logged in than reporting success does. + console.error('[auth] failed to revoke the SuperTokens session during logout', e); + } + } + + res.json({ ok: true }); + }); + + return router; +} diff --git a/server/supertokens/init.js b/server/supertokens/init.js index 6f59aca..6c27b2e 100644 --- a/server/supertokens/init.js +++ b/server/supertokens/init.js @@ -105,6 +105,38 @@ export async function initSuperTokens({ env = process.env, mode } = {}) { return true; } +/** + * Whether SuperTokens is live in this process. + * + * This, not AUTH_MODE, is what the auth chain in server/auth.js branches on. + * The distinction matters: `requireAuth` is module-level middleware shared by + * every route, while the mode is resolved per-`buildApp()` call, so reading the + * mode there would mean guessing which app a request belongs to. Asking + * "has init actually run?" is both simpler and strictly safer - if SuperTokens + * is not initialised, calling into its SDK would throw, and that is exactly + * the condition this answers. + */ +export function isSuperTokensReady() { + return initialised; +} + +/** + * The Session recipe module, or null when SuperTokens is not initialised. + * + * Cached after the first load. The import stays dynamic for the same reason + * every other one in this file does: in `passport` mode the SDK must never be + * loaded, and `requireAuth` runs on every single request in every mode. + */ +let sessionRecipe = null; +export async function loadSessionRecipe() { + if (!initialised) return null; + if (!sessionRecipe) { + const m = await import('supertokens-node/recipe/session'); + sessionRecipe = m.default ?? m; + } + return sessionRecipe; +} + /** Test-only: whether init has run in this process. */ export function __isInitialised() { return initialised; @@ -120,4 +152,5 @@ export function __isInitialised() { */ export function __resetForTests() { initialised = false; + sessionRecipe = null; } diff --git a/tests/supertokens.middleware.test.js b/tests/supertokens.middleware.test.js new file mode 100644 index 0000000..bc7c1f2 --- /dev/null +++ b/tests/supertokens.middleware.test.js @@ -0,0 +1,294 @@ +// The authentication chain across all three AUTH_MODE values. +// +// The guarantee users would actually notice is the one this file spends most +// of its assertions on: a legacy JWT cookie issued BEFORE the switch keeps +// working in `dual` AND in `supertokens` mode. That is the no-forced-logout +// promise and the thing that makes the documented rollback real - a mode flip +// in either direction must never invalidate a cookie that has up to 90 days +// left on it. +// +// SuperTokens is initialised for real here (supertokens.init() is offline - it +// stores config and contacts nothing), so the chain under test is the real +// one. Only the single `Session.getSession` call is stubbed, and only for the +// tests that need a session to exist; everything else exercises the genuine +// "no SuperTokens session present" path, which is exactly what a mid-rollout +// request from an existing player looks like. + +process.env.JWT_SECRET = 'test-secret-for-middleware'; +process.env.SUPER_ADMIN_IDS = ''; + +// configurePassport() reads process.env directly rather than buildApp's `env` +// override, so the OAuth credentials have to live here for the passport +// strategies to register at all. Without them `passport.authenticate('github')` +// throws "Unknown authentication strategy" and the route 500s - which would +// make the "not registered in supertokens mode" test below pass for entirely +// the wrong reason. +process.env.GITHUB_CLIENT_ID = 'gh-id'; +process.env.GITHUB_CLIENT_SECRET = 'gh-secret'; +process.env.GITHUB_CALLBACK_URL = 'https://rackstack.example.com/auth/github/callback'; +process.env.DISCORD_CLIENT_ID = 'dc-id'; +process.env.DISCORD_CLIENT_SECRET = 'dc-secret'; +process.env.DISCORD_CALLBACK_URL = 'https://rackstack.example.com/auth/discord/callback'; + +import { + describe, it, expect, afterAll, beforeAll, vi, +} from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { provisionDatabase } from './helpers/backend.js'; + +const provisioned = await provisionDatabase(); + +const { buildApp } = await import('../server/app.js'); +const { ensureConfig } = await import('../server/configService.js'); +const { upsertUser, putSave, driver } = await import('../server/db.js'); +const { COOKIE_NAME } = await import('../server/auth.js'); +const { loadSessionRecipe, isSuperTokensReady } = await import('../server/supertokens/init.js'); + +await ensureConfig(); + +// A complete SuperTokens configuration. The connection URI is never dialled: +// init() is offline, and getSession with no session tokens on the request +// short-circuits before any network call. +const ST_ENV = { + ...process.env, + SUPERTOKENS_CONNECTION_URI: 'http://supertokens.invalid:3567', + PUBLIC_ORIGIN: 'https://rackstack.example.com', +}; + +const apps = {}; +let player; + +beforeAll(async () => { + player = await upsertUser({ + provider: 'github', providerId: 'chain-1', username: 'chainuser', avatarUrl: null, + }); + await putSave(player.id, { wafers: 77, marker: 'chain' }, 1); + + // Built passport-first so the passport app is constructed before any + // SuperTokens state exists in this process - the closest a single process + // can get to "what a passport-only deployment builds". + apps.passport = await buildApp({ env: { ...process.env, AUTH_MODE: 'passport' } }); + apps.dual = await buildApp({ env: { ...ST_ENV, AUTH_MODE: 'dual' } }); + apps.supertokens = await buildApp({ env: { ...ST_ENV, AUTH_MODE: 'supertokens' } }); +}); + +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + +function legacyCookie(user) { + const token = jwt.sign( + { sub: user.id, username: user.username, avatarUrl: user.avatar_url }, + process.env.JWT_SECRET, + { expiresIn: '90d' }, + ); + return `${COOKIE_NAME}=${token}`; +} + +const MODES = ['passport', 'dual', 'supertokens']; + +describe('the auth chain in every mode', () => { + it.each(MODES)('%s: a legacy JWT cookie authenticates and resolves the right save', async (mode) => { + // In `dual` and `supertokens` this is the no-forced-logout guarantee: a + // cookie issued before the switch still works afterwards. Asserted on the + // save, not just on a 200, because a 200 for the WRONG user is the + // failure this release exists to prevent. + const res = await request(apps[mode]).get('/api/me').set('Cookie', legacyCookie(player)); + expect(res.status).toBe(200); + expect(res.body.id ?? res.body.sub).toBe('github:chain-1'); + + const state = await request(apps[mode]).get('/api/state').set('Cookie', legacyCookie(player)); + expect(state.status).toBe(200); + }); + + it.each(MODES)('%s: an unauthenticated request gets 401, not 500', async (mode) => { + const res = await request(apps[mode]).get('/api/me'); + expect(res.status).toBe(401); + }); + + it.each(MODES)('%s: a garbage cookie gets 401, not 500', async (mode) => { + const res = await request(apps[mode]).get('/api/me').set('Cookie', `${COOKIE_NAME}=not-a-jwt`); + expect(res.status).toBe(401); + }); + + it.each(MODES)('%s: a cookie signed with the wrong secret is rejected', async (mode) => { + const forged = jwt.sign({ sub: player.id, username: 'x' }, 'wrong-secret', { expiresIn: '90d' }); + const res = await request(apps[mode]).get('/api/me').set('Cookie', `${COOKIE_NAME}=${forged}`); + expect(res.status).toBe(401); + }); +}); + +describe('passport route gating', () => { + it.each(['passport', 'dual'])('%s: the passport OAuth routes are registered', async (mode) => { + // 302 to the provider is what passport.authenticate does on success. + const gh = await request(apps[mode]).get('/auth/github'); + expect(gh.status).toBe(302); + expect(gh.headers.location).toContain('github.com'); + + const dc = await request(apps[mode]).get('/auth/discord'); + expect(dc.status).toBe(302); + expect(dc.headers.location).toContain('discord.com'); + }); + + it('supertokens: the passport OAuth routes are not registered at all', async () => { + // Not registered, not merely failing - the request must fall through to + // the SPA fallback rather than reach passport. A route that exists and + // errors would still send a player to a broken GitHub redirect. + for (const path of ['/auth/github', '/auth/discord']) { + const res = await request(apps.supertokens).get(path); + expect(res.status, `${path} should not redirect to a provider`).not.toBe(302); + } + }); + + it.each(MODES)('%s: logout is available and clears the legacy cookie', async (mode) => { + // Registered in every mode. In `supertokens` mode especially: a player can + // still be carrying a pre-cutover cookie, and that cookie is exactly what + // the chain's fallback branch would otherwise keep accepting. + const res = await request(apps[mode]).post('/auth/logout').set('Cookie', legacyCookie(player)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ ok: true }); + + const cleared = (res.headers['set-cookie'] ?? []).join(';'); + expect(cleared).toContain(COOKIE_NAME); + }); +}); + +describe('the SuperTokens branch of the chain', () => { + it('is live once SuperTokens is initialised', () => { + // Guards every stub below: if init had silently not happened, the stubs + // would never be consulted and the tests would pass by testing the JWT + // path twice. + expect(isSuperTokensReady()).toBe(true); + }); + + it('authenticates from a SuperTokens session, with users.id as the subject', async () => { + const Session = await loadSessionRecipe(); + const spy = vi.spyOn(Session, 'getSession').mockResolvedValue({ + // The mapping created in Task 3 is what makes this our id rather than + // SuperTokens' internal one. + getUserId: () => 'github:chain-1', + }); + + try { + // No cookie at all - the only thing authenticating this request is the + // SuperTokens session. + const res = await request(apps.dual).get('/api/me'); + expect(res.status).toBe(200); + expect(res.body.id ?? res.body.sub).toBe('github:chain-1'); + expect(res.body.username).toBe('chainuser'); + } finally { + spy.mockRestore(); + } + }); + + it('falls through to the JWT cookie when the SuperTokens session throws', async () => { + // THE mid-rollout failure mode. A user in `dual` mode carrying a stale or + // malformed SuperTokens session plus a good legacy cookie must be logged + // in by the cookie, not 500'd by the session. Getting this wrong takes + // down logins for everyone mid-migration. + const Session = await loadSessionRecipe(); + const spy = vi.spyOn(Session, 'getSession').mockRejectedValue(new Error('TRY_REFRESH_TOKEN')); + + try { + const res = await request(apps.dual).get('/api/me').set('Cookie', legacyCookie(player)); + expect(res.status).toBe(200); + expect(res.body.id ?? res.body.sub).toBe('github:chain-1'); + } finally { + spy.mockRestore(); + } + }); + + it('401s rather than 500s when the SuperTokens session throws and there is no cookie', async () => { + const Session = await loadSessionRecipe(); + const spy = vi.spyOn(Session, 'getSession').mockRejectedValue(new Error('TRY_REFRESH_TOKEN')); + + try { + const res = await request(apps.dual).get('/api/me'); + expect(res.status).toBe(401); + } finally { + spy.mockRestore(); + } + }); + + it('rejects a session whose subject matches no user row', async () => { + // Would mean the id mapping resolved to something `users` has never heard + // of. Treating that as authenticated would hand a request context a `sub` + // matching no save, no role and no SUPER_ADMIN_IDS entry - the silent + // empty-save outcome. It must not fall back into a half-valid session. + const Session = await loadSessionRecipe(); + const spy = vi.spyOn(Session, 'getSession').mockResolvedValue({ + getUserId: () => 'github:does-not-exist', + }); + + try { + const res = await request(apps.dual).get('/api/me'); + expect(res.status).toBe(401); + } finally { + spy.mockRestore(); + } + }); + + it('prefers the SuperTokens session over a legacy cookie for a different user', async () => { + // Pins the chain's ORDER. If the branches were swapped, a user holding + // both would resolve to the cookie's subject instead - which during a + // cutover means their session silently reverts to whoever the old cookie + // was for. + const other = await upsertUser({ + provider: 'github', providerId: 'chain-2', username: 'otheruser', avatarUrl: null, + }); + const Session = await loadSessionRecipe(); + const spy = vi.spyOn(Session, 'getSession').mockResolvedValue({ + getUserId: () => 'github:chain-1', + }); + + try { + const res = await request(apps.dual).get('/api/me').set('Cookie', legacyCookie(other)); + expect(res.status).toBe(200); + expect(res.body.id ?? res.body.sub).toBe('github:chain-1'); + } finally { + spy.mockRestore(); + } + }); +}); + +describe('requireRole across both stacks', () => { + it('derives roles from req.user.sub regardless of which stack authenticated', async () => { + // requireRole reads req.user.sub and goes to the database and env on every + // request - it never trusts a cached role. Since both branches of the + // chain populate the identical `sub`, it needs no change in v1.8, and this + // asserts that rather than leaving it as a claim in a comment. + const { setRoles } = await import('../server/db.js'); + await setRoles('github:chain-1', ['admin']); + + const viaCookie = await request(apps.dual).get('/api/admin/config').set('Cookie', legacyCookie(player)); + expect(viaCookie.status).toBe(200); + + const Session = await loadSessionRecipe(); + const spy = vi.spyOn(Session, 'getSession').mockResolvedValue({ + getUserId: () => 'github:chain-1', + }); + try { + const viaSession = await request(apps.dual).get('/api/admin/config'); + expect(viaSession.status).toBe(200); + } finally { + spy.mockRestore(); + } + + await setRoles('github:chain-1', []); + }); + + it('403s a non-admin through either stack', async () => { + const Session = await loadSessionRecipe(); + const spy = vi.spyOn(Session, 'getSession').mockResolvedValue({ + getUserId: () => 'github:chain-2', + }); + try { + const viaSession = await request(apps.dual).get('/api/admin/config'); + expect(viaSession.status).toBe(403); + } finally { + spy.mockRestore(); + } + }); +}); From 8e5ffedb3ac8f0f2e5014002c3ce824d9f42df61 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 18:52:13 -0400 Subject: [PATCH 07/14] v1.8 Task 5: the shadow-mode verification gate The gate that has to read 100% before anyone cuts over. Nothing here writes: it issues SELECTs only, touches no session, and is designed to be pointed at production while players are online. The plan framed shadow mode as "given a completed SuperTokens login, compare it against identities". Built literally, that is a gate you can only open after walking through the door - it needs the core reachable and someone logging in through it, which is most of what the gate is meant to clear. The way out is that the risk is asymmetric. The equality has two halves: what SuperTokens will compute, and what is already stored. Task 3 verified the first at the source level for both providers. Only the second cannot be checked by reading code, because those rows were written by whatever library versions were installed the day each player first logged in. So the gate audits exactly that half - for every identity row, does user_id equal provider:provider_id? No core, no login, no cutover; it runs against a restored export on a laptop. `npm run shadow:check` is the operator entry point and exits 0 only on a clean pass. The per-login form (createShadowRun) was still built and is useful once dual is on, but the runbook is explicit that the audit is the gate. An empty run exits non-zero and says GATE: NOT RUN. Zero comparisons gives a 100% match rate by vacuous arithmetic, and a gate that passed because it read nothing would manufacture exactly the false confidence it exists to prevent - most likely on a mistyped DATABASE_URL, i.e. when an operator is least able to notice. Verified by running the CLI against an empty database. no-identity rows are counted separately and excluded from the rate: a player who has never logged in is evidence neither for nor against the assumption. The no-write property is asserted table-wide, by snapshotting all of identities around a run. "The row I looked at is unchanged" is not the guarantee that matters here. Not run against production identities - the owner's export has not been supplied and v1.7 has not been cut over. The runbook says so plainly. 587 tests green on SQLite, 610 on Postgres. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-06-v1.8-supertokens.md | 56 +++- docs/supertokens-rollout-runbook.md | 91 +++++- package.json | 1 + server/supertokens/shadow.js | 265 ++++++++++++++++++ server/supertokens/shadowCheck.js | 35 +++ tests/supertokens.shadow.test.js | 215 ++++++++++++++ 6 files changed, 646 insertions(+), 17 deletions(-) create mode 100644 server/supertokens/shadow.js create mode 100644 server/supertokens/shadowCheck.js create mode 100644 tests/supertokens.shadow.test.js diff --git a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md index bb064b5..a9a57d1 100644 --- a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md +++ b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md @@ -213,19 +213,19 @@ Spec §5.5. This exists because "SuperTokens' `thirdPartyUserId` equals passport - Create: `server/supertokens/shadow.js`, `tests/supertokens.shadow.test.js` - Modify: `docs/supertokens-rollout-runbook.md` -- [ ] **Step 1: Implement the comparison** +- [x] **Step 1: Implement the comparison** Given a completed SuperTokens third-party login, compute `` `${thirdPartyId}:${thirdPartyUserId}` `` and compare against `identities`. Report match / mismatch / no-such-identity. **It must not alter the caller's session or write anything** — that is what makes it safe to run against production. -- [ ] **Step 2: Make the result legible to an operator** +- [x] **Step 2: Make the result legible to an operator** A per-login log line plus a summary suitable for the cutover decision: total compared, matched, mismatched, with the mismatching pairs named. The gate is 100%; anything less must be visibly not-100%, not buried. -- [ ] **Step 3: Test both outcomes** +- [x] **Step 3: Test both outcomes** Matching and deliberately mismatching id shapes, plus the "identity does not exist yet" case. Assert the no-write property directly — snapshot the identities table before and after and compare. -- [ ] **Step 4: Document the gate** +- [x] **Step 4: Document the gate** The runbook states plainly: cutover to `dual` is gated on 100%, and the check is run against the owner's production export. Record that this has **not** been run yet. @@ -455,6 +455,54 @@ exactly the ordering test. Neither mutation was caught by luck elsewhere. > cutting over to `supertokens` needs frontend refresh handling first. Recorded > here and carried into the runbook in Task 7. +### Task 5 + +**The gate is an offline audit of stored rows, not a live login.** The plan +(and design §5.5) framed shadow mode as "given a completed SuperTokens +third-party login, compare it against `identities`". Built as specified, that +is a gate you can only open after walking through the door: it needs the +SuperTokens core reachable and a real login through it, which is most of what +the gate is supposed to clear beforehand. + +The resolution comes from noticing that the risk is asymmetric. The equality +has two halves — what SuperTokens *will* compute, and what is *already stored* +— and Task 3 verified the first at the source level for both providers. Only +the second is unverifiable by reading code, because those rows were written by +whatever library versions were installed the day each player first logged in. +`auditStoredIdentities()` reads exactly that half: for every row, does +`user_id` equal `provider:provider_id`? It needs no core, no login, no +cutover, and runs against a plain restored export on a laptop. + +The per-login form was still built (`createShadowRun`), since it is genuinely +useful as belt-and-braces once `dual` is on. The runbook is explicit that C2, +the audit, is *the* gate and the live check is optional. + +**`npm run shadow:check` is the operator entry point**, exiting 0 only on a +clean pass. Not in the plan, which described a library but no way to run it. + +**An empty run exits non-zero and reports `GATE: NOT RUN`.** Zero comparisons +gives a 100% match rate by vacuous arithmetic, and a gate that reported PASS +because it read nothing would manufacture precisely the false confidence it +exists to prevent — most likely on a mistyped `DATABASE_URL`, i.e. exactly when +an operator is least able to notice. Tested directly, and verified by running +the CLI against an empty database. + +**`no-identity` is counted separately and excluded from the rate.** A player +who has never logged in is neither evidence for nor against the assumption. +Folding them into the mismatch count would make the gate unreadable on any +server that has ever had a signup; folding them into the match count would let +new players prop up a failing rate. + +**No new db interface function was needed.** The audit enumerates via +`getAllUsersWithSaves` + `listIdentities` rather than adding a "list every +identity" read. Slightly N+1, entirely fine for a one-off operator script, and +it keeps Task 3's "exactly two new functions" boundary intact. + +**The no-write property is asserted table-wide**, by snapshotting the whole +`identities` table around a run and comparing. Checking only the row under test +would not be the guarantee that matters, which is that this is safe to point at +production while people are playing. + ### Correction to the design Spec §5.5 called the "SuperTokens `thirdPartyUserId` equals passport's diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index be24cb8..6942982 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -15,9 +15,9 @@ Do not set `AUTH_MODE` to anything but blank or `passport` yet. |---|---|---| | `AUTH_MODE` switch + validation | 1 | ✅ built | | SuperTokens init + provider config | 2 | ✅ built | -| Identity mapping (`signInUp` override) | 3 | ⬜ not started | -| Auth middleware chain | 4 | ⬜ not started | -| Shadow-mode verification | 5 | ⬜ not started | +| Identity mapping (`signInUp` override) | 3 | ✅ built | +| Auth middleware chain | 4 | ✅ built | +| Shadow-mode verification | 5 | ✅ built — **not yet run against production** | | OAuth callback URL changes | 6 | 📄 documented below, not yet needed | | Deployment config + release | 7 | ⬜ not started | @@ -232,19 +232,84 @@ step that actually changes behaviour. ## Part C — Shadow-mode verification gate -*Pending Task 5.* +**Cutover to `dual` is gated on this reporting 100%.** Nothing in Part D +happens until it does. -Will cover: running a SuperTokens login in shadow mode, which computes -`provider:thirdPartyUserId` and compares it against the existing `identities` -rows without touching the caller's session or writing anything. +### C1. Why there is a gate at all -**Cutover is gated on a 100% match.** This exists because the assumption that -SuperTokens' `thirdPartyUserId` equals passport's `profile.id` is load-bearing -and unverified — and if it is wrong, the symptom is a player silently landing -on a brand-new empty save rather than an error anyone would notice. +The release rests on one equality: the id SuperTokens computes for a provider +is the same string passport already stored. That has two halves, and only one +of them can be checked by reading code: -**This has not been run against production identities.** It cannot be until -the owner's export is available. +| Half | How it was checked | Result | +|---|---|---| +| What SuperTokens *will* compute | Read both providers' source at the pinned versions | Verified — see "The one assumption" above | +| What is *already stored* in your `identities` rows | Cannot be read from code. Those rows were written by whatever library versions were installed the day each player first logged in, going back to v1.0. | **This is what Part C checks** | + +If they ever disagreed, there would be no error and no log line. A returning +player would simply land on a brand-new empty save — and if they played on it +before anyone noticed, their old save could only come back from a restore. + +### C2. Run the audit (do this first — it needs nothing switched on) + +```bash +npm run shadow:check +``` + +It reads whichever database your usual environment variables point at +(`DATABASE_URL`, or `DB_PATH` for SQLite) and checks every identity row. + +**It is read-only.** It issues nothing but SELECTs, touches no session, and +creates nothing. Safe to run against production with players online — and safe +to run against a restored export on a laptop, which is the intended use, since +this has to clear *before* the SuperTokens stack is switched on. + +A clean run: + +``` +[shadow] MATCH github:37058311 -> github:37058311 +[shadow] MATCH discord:536626725380161537 -> discord:536626725380161537 + +=== SuperTokens shadow-mode report === +logins compared: 2 +matched: 2 +mismatched: 0 +no existing identity: 0 (new players - not failures) +match rate: 100.00% + +GATE: PASS - 100% of comparable logins matched. Cutover to AUTH_MODE=dual is cleared. +``` + +Exit code 0 means pass; anything else means do not proceed. + +### C3. Reading the result + +| Report says | Meaning | Do | +|---|---|---| +| `GATE: PASS` | Every stored identity has the shape the mapping expects. | Proceed to Part D. | +| `GATE: FAIL` | One or more players would land on the wrong save. Every offending pair is named in the output. | **Stop.** Do not set `AUTH_MODE`. This needs looking at per row. | +| `GATE: NOT RUN` | Nothing was compared — usually the wrong database. | Check `DATABASE_URL`/`DB_PATH`. An empty run is **not** a pass. | + +That last row is why the script exits non-zero on an empty run: a gate that +reported success because it read nothing would manufacture exactly the false +confidence it exists to prevent. + +### C4. The live per-login check (optional, during `dual`) + +`createShadowRun()` in `server/supertokens/shadow.js` does the same comparison +for an actual completed SuperTokens login, logging one line each, and likewise +writes nothing and does not touch the caller's session. It is useful as +belt-and-braces once `dual` is on, but it cannot be the gate — it needs the +SuperTokens stack reachable and someone logging in through it, which is most of +what the gate is meant to clear beforehand. **C2 is the gate.** + +### C5. Status + +> **This has not been run against production identities.** The owner's current +> Unraid export has not been supplied, and v1.7 has not been cut over on that +> box yet. The audit is built and tested — including against a database +> deliberately containing a bad row — but it has only ever run against test +> data. **No cutover has happened, and none is cleared.** ## Part D — Cutover diff --git a/package.json b/package.json index 51c031b..616a28c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:sqlite": "TEST_BACKEND=sqlite vitest run", "test:all": "npm run test:sqlite && npm test", "migrate:pg": "node server/db/migrate.js", + "shadow:check": "node server/supertokens/shadowCheck.js", "smoke": "for f in tests/e2e/smoke-v1*.mjs; do node \"$f\" || exit 1; done", "smoke:pg": "TEST_BACKEND=pg npm run smoke" }, diff --git a/server/supertokens/shadow.js b/server/supertokens/shadow.js new file mode 100644 index 0000000..49a0031 --- /dev/null +++ b/server/supertokens/shadow.js @@ -0,0 +1,265 @@ +// Shadow mode: the gate that has to read 100% before anyone cuts over. +// +// The whole release rests on one equality - that SuperTokens' `thirdPartyUserId` +// is the same string passport stored as `provider_id`. Task 3's implementation +// notes record that this has now been verified at the SOURCE level for both +// providers at their pinned versions, which is a real improvement on "assumed". +// It is still not sufficient, and this module exists because of the gap: +// +// Reading the libraries tells you what they will write TOMORROW. +// The rows in `identities` were written by whatever versions were installed +// on the day each player first logged in, going back to v1.0. +// +// So the only evidence that actually settles it is the owner's production +// `identities` table. This module compares a real SuperTokens login against +// those real rows and reports what it finds. +// +// The failure this prevents is specific and unrecoverable: a mismatched id +// means a returning player is treated as brand new, silently lands on an empty +// save, and - if they play on it before anyone notices - cannot be given their +// old one back without a restore. There is no error and no log line at the +// moment it happens. Hence a gate, run before the switch, rather than +// monitoring afterwards. +// +// SAFETY: nothing in this file writes. It is designed to be run against +// production while players are logged in and playing, so it does not touch the +// caller's session, does not create users, and issues no statement other than +// the SELECT inside `getIdentity`. tests/supertokens.shadow.test.js asserts +// that by snapshotting the whole identities table around a run. + +import { + getIdentity as dbGetIdentity, + getAllUsersWithSaves as dbGetAllUsersWithSaves, + listIdentities as dbListIdentities, +} from '../db/index.js'; + +const defaultDb = { + getIdentity: dbGetIdentity, + getAllUsersWithSaves: dbGetAllUsersWithSaves, + listIdentities: dbListIdentities, +}; + +/** The three things a comparison can conclude. */ +export const SHADOW_MATCH = 'match'; +export const SHADOW_MISMATCH = 'mismatch'; +export const SHADOW_NO_IDENTITY = 'no-identity'; + +/** + * Compares one completed SuperTokens third-party login against `identities`. + * + * Returns a plain result object; it never throws for a mismatch, because a + * mismatch is a finding to be reported rather than an error to be handled. + * + * `no-identity` is NOT a failure. It is what a genuinely new player looks + * like, and also what a player who has simply never logged in through this + * provider looks like. Conflating it with `mismatch` would make the gate + * unreadable on any server that has ever had a new signup - which is why the + * summary below counts the three outcomes separately. + */ +export async function compareIdentity( + { thirdPartyId, thirdPartyUserId }, + db = defaultDb, +) { + const expectedUserId = `${thirdPartyId}:${thirdPartyUserId}`; + const identity = await db.getIdentity(thirdPartyId, thirdPartyUserId); + + if (!identity) { + return { + outcome: SHADOW_NO_IDENTITY, + thirdPartyId, + thirdPartyUserId, + expectedUserId, + actualUserId: null, + }; + } + + return { + outcome: identity.user_id === expectedUserId ? SHADOW_MATCH : SHADOW_MISMATCH, + thirdPartyId, + thirdPartyUserId, + expectedUserId, + actualUserId: identity.user_id, + }; +} + +/** One line per login, so a tail of the logs during shadow mode is readable. */ +export function formatResult(result) { + const who = `${result.thirdPartyId}:${result.thirdPartyUserId}`; + switch (result.outcome) { + case SHADOW_MATCH: + return `[shadow] MATCH ${who} -> ${result.actualUserId}`; + case SHADOW_MISMATCH: + return `[shadow] MISMATCH ${who} - SuperTokens implies '${result.expectedUserId}' ` + + `but identities has '${result.actualUserId}'. This player would land on the WRONG save.`; + default: + return `[shadow] NO-IDENTITY ${who} - no such row; this is a new player, not a failure.`; + } +} + +/** + * Rolls a set of results into the number an operator makes the cutover + * decision on. + * + * `passed` is true only when there is at least one comparison AND no + * mismatches. The "at least one" clause is the important half: an empty run + * has a 100% match rate by vacuous arithmetic, and a gate that reports PASS + * because it compared nothing is worse than no gate at all - it manufactures + * exactly the false confidence the gate exists to prevent. A run that compared + * nothing has not been run. + */ +export function summarise(results) { + const matched = results.filter((r) => r.outcome === SHADOW_MATCH); + const mismatched = results.filter((r) => r.outcome === SHADOW_MISMATCH); + const missing = results.filter((r) => r.outcome === SHADOW_NO_IDENTITY); + + return { + total: results.length, + matched: matched.length, + mismatched: mismatched.length, + noIdentity: missing.length, + // Percentage of comparisons that had something to compare against. + matchRate: matched.length + mismatched.length === 0 + ? null + : matched.length / (matched.length + mismatched.length), + mismatches: mismatched.map((r) => ({ + thirdPartyId: r.thirdPartyId, + thirdPartyUserId: r.thirdPartyUserId, + expectedUserId: r.expectedUserId, + actualUserId: r.actualUserId, + })), + passed: results.length > 0 && mismatched.length === 0, + }; +} + +/** + * The summary an operator reads before deciding to cut over. + * + * Deliberately blunt. Anything other than a clean pass says so on its own + * line, in words, with every mismatching pair named - a gate whose failure + * has to be inferred from a percentage is a gate people talk themselves past + * at the end of a long maintenance window. + */ +export function formatSummary(summary) { + const lines = [ + '=== SuperTokens shadow-mode report ===', + `logins compared: ${summary.total}`, + `matched: ${summary.matched}`, + `mismatched: ${summary.mismatched}`, + `no existing identity: ${summary.noIdentity} (new players - not failures)`, + ]; + + if (summary.matchRate !== null) { + lines.push(`match rate: ${(summary.matchRate * 100).toFixed(2)}%`); + } + + if (summary.mismatches.length > 0) { + lines.push('', 'MISMATCHES - these players would land on the wrong save:'); + for (const m of summary.mismatches) { + lines.push( + ` ${m.thirdPartyId}:${m.thirdPartyUserId} - SuperTokens implies ` + + `'${m.expectedUserId}', identities has '${m.actualUserId}'`, + ); + } + } + + lines.push(''); + if (summary.passed) { + lines.push('GATE: PASS - 100% of comparable logins matched. Cutover to AUTH_MODE=dual is cleared.'); + } else if (summary.total === 0) { + lines.push( + 'GATE: NOT RUN - nothing was compared. This is not a pass. Run at least one ' + + 'real login through shadow mode before cutting over.', + ); + } else { + lines.push( + `GATE: FAIL - ${summary.mismatched} mismatch(es). Do NOT cut over. ` + + 'Every mismatch is a player who would silently land on an empty save.', + ); + } + + return lines.join('\n'); +} + +/** + * Audits every identity row already in the database, without any login + * happening at all. + * + * This is the form of the gate that can actually be run BEFORE cutover, and it + * is the one the runbook tells the operator to use first. The live per-login + * form below needs the SuperTokens stack to be reachable and someone to log in + * through it — which is most of the thing the gate is supposed to clear — so on + * its own it would be a gate you can only open after walking through the door. + * + * It works because the residual risk is entirely on one side. The equality this + * release rests on has two halves: + * + * 1. What SuperTokens will compute for `thirdPartyUserId`. Verified at the + * source level for both providers at their pinned versions (design §5.3). + * 2. What is actually stored in `identities.provider_id`, written by whatever + * library versions were installed on the day each player first logged in. + * + * Only (2) is unverifiable by reading code, and (2) is exactly what this reads. + * For every row it asks the one question that matters: does `user_id` equal + * `provider:provider_id`? If that holds for 100% of rows, then any login whose + * `thirdPartyUserId` matches `provider_id` resolves to the right save. + * + * Enumerates through `getAllUsersWithSaves` + `listIdentities` rather than a + * new "list every identity" interface function, so it needs no schema or + * interface change and runs against a plain restored export. + * + * Read-only, like everything else here. + */ +export async function auditStoredIdentities({ db = defaultDb, log = () => {} } = {}) { + const users = await db.getAllUsersWithSaves(); + const results = []; + + for (const user of users) { + // eslint-disable-next-line no-await-in-loop + const identities = await db.listIdentities(user.id); + for (const identity of identities) { + const expectedUserId = `${identity.provider}:${identity.provider_id}`; + const result = { + outcome: identity.user_id === expectedUserId ? SHADOW_MATCH : SHADOW_MISMATCH, + thirdPartyId: identity.provider, + thirdPartyUserId: identity.provider_id, + expectedUserId, + actualUserId: identity.user_id, + }; + results.push(result); + log(formatResult(result)); + } + } + + return results; +} + +/** + * Collects shadow results across a run. + * + * Kept as an explicit collector rather than module-level state so two runs + * cannot contaminate each other, and so a caller can hold one per operator + * session. + */ +export function createShadowRun({ db = defaultDb, log = console.log } = {}) { + const results = []; + + return { + results, + + /** Compare one login. Read-only; safe to call on a live server. */ + async record(input) { + const result = await compareIdentity(input, db); + results.push(result); + log(formatResult(result)); + return result; + }, + + summary() { + return summarise(results); + }, + + report() { + return formatSummary(summarise(results)); + }, + }; +} diff --git a/server/supertokens/shadowCheck.js b/server/supertokens/shadowCheck.js new file mode 100644 index 0000000..3248109 --- /dev/null +++ b/server/supertokens/shadowCheck.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// Operator entry point for the shadow-mode gate: `npm run shadow:check`. +// +// Audits every identity row in whichever database the usual environment +// variables point at (DATABASE_URL for Postgres, DB_PATH for SQLite - the same +// resolution the server itself uses, so there is no second place to get it +// wrong) and prints the report the cutover decision is made from. +// +// Read-only. Safe to run against production while players are online, and safe +// to run against a restored export on a laptop - which is the intended use, +// since the gate has to clear BEFORE the SuperTokens stack is switched on. +// +// Exit code is the machine-readable form of the gate: 0 only on a clean pass. +// A non-zero exit on "nothing was compared" is deliberate - an empty run is not +// a pass, and a script that exited 0 on it would quietly bless a cutover +// against a database it never actually read. + +import { auditStoredIdentities, summarise, formatSummary } from './shadow.js'; +import { driver } from '../db/index.js'; + +async function main() { + const results = await auditStoredIdentities({ log: (line) => console.log(line) }); + const summary = summarise(results); + + console.log(''); + console.log(formatSummary(summary)); + + if (driver.__backend === 'pg') await driver.__raw.end(); + process.exit(summary.passed ? 0 : 1); +} + +main().catch((e) => { + console.error('[shadow] the audit failed to run:', e); + process.exit(2); +}); diff --git a/tests/supertokens.shadow.test.js b/tests/supertokens.shadow.test.js new file mode 100644 index 0000000..18bca57 --- /dev/null +++ b/tests/supertokens.shadow.test.js @@ -0,0 +1,215 @@ +// Shadow mode is the gate that clears the cutover, so the two things it must +// never do are: report PASS when it should not, and write anything. +// +// The no-write property gets a snapshot of the entire identities table taken +// around a run and compared byte-for-byte, rather than a check that the one +// row under test is unchanged. The point of shadow mode is that it can be run +// against a live production database while people are playing, and "the row I +// looked at is fine" is not that guarantee. + +import { describe, it, expect, afterAll } from 'vitest'; +import { provisionDatabase } from './helpers/backend.js'; + +const provisioned = await provisionDatabase(); + +const dbMod = await import('../server/db.js'); +const { driver, upsertUser } = dbMod; + +const { + compareIdentity, summarise, formatSummary, formatResult, createShadowRun, + auditStoredIdentities, + SHADOW_MATCH, SHADOW_MISMATCH, SHADOW_NO_IDENTITY, +} = await import('../server/supertokens/shadow.js'); + +afterAll(async () => { + if (driver.__backend === 'pg') await driver.__raw.end(); + await provisioned.cleanup(); +}); + +async function snapshotIdentities() { + const sql = 'SELECT provider, provider_id, user_id, supertokens_user_id, created_at, last_login_at ' + + 'FROM identities ORDER BY provider, provider_id'; + const rows = driver.__backend === 'sqlite' + ? driver.__raw.prepare(sql).all() + : (await driver.__raw.query(sql)).rows; + return JSON.stringify(rows); +} + +describe('shadow-mode comparison', () => { + it('reports a match when the stored identity is what SuperTokens implies', async () => { + await upsertUser({ + provider: 'github', providerId: '37058311', username: 'nec', avatarUrl: null, + }); + + const result = await compareIdentity({ thirdPartyId: 'github', thirdPartyUserId: '37058311' }); + expect(result.outcome).toBe(SHADOW_MATCH); + expect(result.expectedUserId).toBe('github:37058311'); + expect(result.actualUserId).toBe('github:37058311'); + }); + + it('reports a mismatch when identities points at a different user', async () => { + // The shape that would actually bite: an identity row whose user_id is not + // `provider:provider_id`. Written directly, because upsertUser cannot + // produce it - which is the point, since the rows this gate examines were + // written by older code, not by today's. + const now = Date.now(); + await upsertUser({ + provider: 'discord', providerId: 'legacy-owner', username: 'legacy', avatarUrl: null, + }); + if (driver.__backend === 'sqlite') { + driver.__raw.prepare( + 'INSERT INTO identities (provider, provider_id, user_id, created_at) VALUES (?, ?, ?, ?)', + ).run('discord', 'odd-shape', 'discord:legacy-owner', now); + } else { + await driver.__raw.query( + 'INSERT INTO identities (provider, provider_id, user_id, created_at) VALUES ($1, $2, $3, $4)', + ['discord', 'odd-shape', 'discord:legacy-owner', now], + ); + } + + const result = await compareIdentity({ thirdPartyId: 'discord', thirdPartyUserId: 'odd-shape' }); + expect(result.outcome).toBe(SHADOW_MISMATCH); + expect(result.expectedUserId).toBe('discord:odd-shape'); + expect(result.actualUserId).toBe('discord:legacy-owner'); + }); + + it('reports no-identity for a player who has never logged in, and does not call it a mismatch', async () => { + const result = await compareIdentity({ thirdPartyId: 'github', thirdPartyUserId: 'never-seen' }); + expect(result.outcome).toBe(SHADOW_NO_IDENTITY); + expect(result.actualUserId).toBeNull(); + // Conflating this with a mismatch would make the gate unreadable on any + // server that has ever had a new signup. + expect(result.outcome).not.toBe(SHADOW_MISMATCH); + }); + + it('writes absolutely nothing - the whole identities table is unchanged', async () => { + // Table-wide, not row-wide. Shadow mode's entire value is that it is safe + // to point at production while people are playing. + const before = await snapshotIdentities(); + + const run = createShadowRun({ log: () => {} }); + await run.record({ thirdPartyId: 'github', thirdPartyUserId: '37058311' }); + await run.record({ thirdPartyId: 'discord', thirdPartyUserId: 'odd-shape' }); + await run.record({ thirdPartyId: 'github', thirdPartyUserId: 'never-seen' }); + + expect(await snapshotIdentities()).toBe(before); + }); +}); + +describe('the offline audit (the form the gate actually runs before cutover)', () => { + it('audits every stored identity without a single login happening', async () => { + // This is what `npm run shadow:check` does against the owner's export. It + // needs no SuperTokens core, no login, and no cutover - which is the point, + // since a gate you can only open after walking through the door is not a + // gate. + const results = await auditStoredIdentities(); + expect(results.length).toBeGreaterThan(0); + + // The deliberately-odd row inserted above must be caught. + const odd = results.find((r) => r.thirdPartyUserId === 'odd-shape'); + expect(odd).toBeDefined(); + expect(odd.outcome).toBe(SHADOW_MISMATCH); + expect(odd.actualUserId).toBe('discord:legacy-owner'); + + // ...and the normal rows must not be. + const good = results.find((r) => r.thirdPartyUserId === '37058311'); + expect(good.outcome).toBe(SHADOW_MATCH); + }); + + it('writes nothing while auditing', async () => { + const before = await snapshotIdentities(); + await auditStoredIdentities(); + expect(await snapshotIdentities()).toBe(before); + }); + + it('never reports no-identity - every row it reads exists by construction', async () => { + // Distinguishes the audit from the per-login path. Reading rows out of the + // table cannot produce a "this player does not exist" outcome, so a + // no-identity here would mean the enumeration had gone wrong. + const results = await auditStoredIdentities(); + expect(results.some((r) => r.outcome === SHADOW_NO_IDENTITY)).toBe(false); + }); + + it('fails the gate on a real database containing a bad row', async () => { + // End to end: audit this database, summarise, and confirm the operator is + // told not to cut over - and told which pair is the problem. + const summary = summarise(await auditStoredIdentities()); + expect(summary.passed).toBe(false); + const report = formatSummary(summary); + expect(report).toContain('GATE: FAIL'); + expect(report).toContain('odd-shape'); + }); +}); + +describe('the gate', () => { + const match = { outcome: SHADOW_MATCH, thirdPartyId: 'github', thirdPartyUserId: '1', expectedUserId: 'github:1', actualUserId: 'github:1' }; + const mismatch = { outcome: SHADOW_MISMATCH, thirdPartyId: 'github', thirdPartyUserId: '2', expectedUserId: 'github:2', actualUserId: 'github:other' }; + const missing = { outcome: SHADOW_NO_IDENTITY, thirdPartyId: 'github', thirdPartyUserId: '3', expectedUserId: 'github:3', actualUserId: null }; + + it('passes only on 100% of comparable logins', () => { + expect(summarise([match, match]).passed).toBe(true); + expect(summarise([match, mismatch]).passed).toBe(false); + // A single mismatch among many matches is still a fail - 99% is a player. + expect(summarise([match, match, match, match, mismatch]).passed).toBe(false); + }); + + it('does NOT pass an empty run', () => { + // The vacuous-pass trap. An empty run has a 100% match rate by arithmetic, + // and a gate that reports PASS because it compared nothing manufactures + // exactly the false confidence it exists to prevent. + const summary = summarise([]); + expect(summary.passed).toBe(false); + expect(summary.matchRate).toBeNull(); + expect(formatSummary(summary)).toContain('NOT RUN'); + expect(formatSummary(summary)).not.toContain('PASS -'); + }); + + it('does not let new players drag the rate down or prop it up', () => { + // no-identity rows are excluded from the rate entirely: they are neither + // evidence for nor against the id-shape assumption. + const summary = summarise([match, missing, missing]); + expect(summary.matchRate).toBe(1); + expect(summary.noIdentity).toBe(2); + expect(summary.passed).toBe(true); + + const failing = summarise([match, mismatch, missing]); + expect(failing.matchRate).toBe(0.5); + expect(failing.passed).toBe(false); + }); + + it('names every mismatching pair in the report rather than burying a percentage', () => { + const report = formatSummary(summarise([match, mismatch])); + expect(report).toContain('GATE: FAIL'); + expect(report).toContain('github:2'); + expect(report).toContain('github:other'); + expect(report).toContain('wrong save'); + }); + + it('says PASS in words, not just as a number', () => { + const report = formatSummary(summarise([match, match])); + expect(report).toContain('GATE: PASS'); + expect(report).toContain('100.00%'); + }); + + it('logs one legible line per login', () => { + expect(formatResult(match)).toContain('MATCH'); + expect(formatResult(mismatch)).toContain('MISMATCH'); + expect(formatResult(mismatch)).toContain('WRONG save'); + expect(formatResult(missing)).toContain('not a failure'); + }); + + it('collects a run end to end', async () => { + const logged = []; + const run = createShadowRun({ log: (line) => logged.push(line) }); + await run.record({ thirdPartyId: 'github', thirdPartyUserId: '37058311' }); + await run.record({ thirdPartyId: 'discord', thirdPartyUserId: 'odd-shape' }); + + expect(logged).toHaveLength(2); + const summary = run.summary(); + expect(summary.total).toBe(2); + expect(summary.matched).toBe(1); + expect(summary.mismatched).toBe(1); + expect(summary.passed).toBe(false); + expect(run.report()).toContain('GATE: FAIL'); + }); +}); From 340ccc939b15c927358178d20798ade947e26af1 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 18:58:20 -0400 Subject: [PATCH 08/14] v1.8 Tasks 6 and 7: OAuth guidance, deployment docs, release prep Task 6 was mostly satisfied by the runbook's Part A already. The remaining work was propagating the same guidance into the two other places an operator configures OAuth - README's setup section and the provider blocks in .env.example - so someone setting the project up today is told about the GitHub callback widening before they hit it, not only someone who opens the runbook. All three restate that the widening is additive and reversible, that the CALLBACK_URL variables stay pointing at the passport paths, and that it must happen before AUTH_MODE changes. Task 7 finishes the runbook (cutover, rollback, and a D6 that explains why supertokens-only mode is not recommended yet), bumps to 1.8.0, and writes the changelog. New: `[auth] SuperTokens initialised (...)` at boot. Found while doing the real-boot verification - all three modes came up clean and completely silently, so nothing in the log an operator actually reads distinguished a working dual boot from one that had quietly not initialised SuperTokens. The line names the mode, the core URI and the providers. Verified by booting `node server/index.js` for real in each mode and probing over HTTP, not just checking for a listening line: passport /auth/github 302 SuperTokens NOT initialised dual /auth/github 302 initialised, providers=github,discord supertokens /auth/github 200 initialised, providers=github,discord That is the containment guarantee and the route gating confirmed at the process level rather than only under supertest. client/package.json deliberately stays at 1.5.0 - client/vite.config.js reads the root package.json as the single version authority. The runbook now LEADS with what has not been verified: shadow mode never run against production identities, no cutover anywhere, v1.7 itself not yet cut over on Unraid, no SuperTokens core ever run against this code outside tests, and supertokens-only mode pending frontend refresh work. v1.7's runbook had to be corrected for implying more had been rehearsed than had. 587 tests on SQLite, 610 on Postgres, 39 smoke assertions, all green. Co-Authored-By: Claude Opus 5 --- .env.example | 14 +++ CHANGELOG.md | 45 ++++++++ Dockerfile | 2 +- README.md | 24 +++++ .../plans/2026-08-06-v1.8-supertokens.md | 64 +++++++++-- .../2026-08-01-postgres-supertokens-design.md | 19 ++-- docs/supertokens-rollout-runbook.md | 101 ++++++++++++++++-- package.json | 2 +- server/supertokens/init.js | 9 ++ 9 files changed, 257 insertions(+), 23 deletions(-) diff --git a/.env.example b/.env.example index f7d4598..c41dab3 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,11 @@ SUPER_ADMIN_IDS= # --- Discord OAuth --- # Create an app at https://discord.com/developers/applications # -> OAuth2 -> add a redirect matching DISCORD_CALLBACK_URL below +# +# Enabling SuperTokens later: ADD a second redirect URI, +# https://your-domain.example.com/auth/callback/discord, alongside the one +# below. Discord permits several. Keep both - passport uses the one below and +# keeps working. Leave DISCORD_CALLBACK_URL itself unchanged. DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= DISCORD_CALLBACK_URL=https://your-domain.example.com/auth/discord/callback @@ -88,6 +93,15 @@ DISCORD_CALLBACK_URL=https://your-domain.example.com/auth/discord/callback # --- GitHub OAuth --- # Create an app at https://github.com/settings/developers -> New OAuth App # -> Authorization callback URL must match GITHUB_CALLBACK_URL below +# +# Enabling SuperTokens later: WIDEN the app's registered Authorization callback +# URL to the parent path https://your-domain.example.com/auth . GitHub requires +# a redirect URL to be a subdirectory of what is registered, and SuperTokens' +# /auth/callback/github is NOT a subdirectory of /auth/github/callback - so +# without this, every SuperTokens GitHub login fails with a redirect_uri +# mismatch. Widening is additive and reversible: both paths then qualify and +# passport keeps working throughout. Do it BEFORE setting AUTH_MODE, and leave +# GITHUB_CALLBACK_URL below unchanged - it tells passport where to send people. GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= GITHUB_CALLBACK_URL=https://your-domain.example.com/auth/github/callback diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ffa888..c80026e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## v1.8.0 + +SuperTokens as an alternative login stack, behind a switch that is off by +default. + +**Upgrading changes nothing.** `AUTH_MODE` defaults to `passport`, which is +byte-for-byte the login stack that shipped in v1.7 — SuperTokens is not +initialised, its middleware is not mounted, and the SDK is not even imported. +Everything below is inert until an operator opts in. + +- **`AUTH_MODE` switch**: `passport` (default), `dual` (both stacks live, + sessions from either accepted), `supertokens` (legacy OAuth routes not + registered). An unrecognised value stops the container on purpose rather + than quietly serving the legacy stack — a typo that looked like a completed + rollout would be discovered weeks later. +- **Your account and save are untouched.** RackStack identifies players by + `users.id` (`provider:providerId`, e.g. `github:37058311`). SuperTokens + issues its own internal id and that id is mapped *onto* the existing one, so + `session.getUserId()` returns exactly what the old JWT carried. No save is + rewritten, no id renumbered, no foreign key moved, and `SUPER_ADMIN_IDS` + keeps working. There is no "migrate your account" step for players. +- **Nobody is logged out, in either direction.** Existing cookies are 90-day + JWTs and stay valid through every mode change. Rollback is setting + `AUTH_MODE` back to `passport` and restarting; unlike the v1.7 Postgres + migration there is no one-way door, because changing the mode rewrites no + data. +- **Shadow-mode verification gate**: `npm run shadow:check` audits every + stored identity and reports whether the id mapping would resolve correctly, + before anything is switched on. Read-only — safe against production with + players online, and against a restored export on a laptop. Cutover is gated + on a 100% match. An empty run reports `NOT RUN` and exits non-zero rather + than passing on having read nothing. +- **Two new repository functions**, `getIdentity` and `setSupertokensUserId`, + implemented on both the SQLite and Postgres drivers. `npm run test:all` + still runs the whole suite against both backends. +- **Operator runbook**: `docs/supertokens-rollout-runbook.md` covers the OAuth + redirect widening (additive and reversible — nothing is removed, so passport + keeps working), standing up the core, the shadow gate, cutover and rollback. + +**Not yet run anywhere.** Shadow mode has not been run against production +identities, and no cutover has happened. `dual` is the intended resting state +for this release: `supertokens`-only mode is implemented and tested but needs +frontend token-refresh handling before it is cut over to. The runbook says all +of this in its opening section. + ## v1.7.0 Postgres support, with automatic migration from SQLite. diff --git a/Dockerfile b/Dockerfile index 192ca98..33330f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,7 @@ LABEL org.opencontainers.image.licenses="MIT" # only on a pushed vX.Y.Z tag, and docker/metadata-action derives the # published image's version label from that tag - so this literal only # affects locally-built images, not what GHCR publishes. -LABEL org.opencontainers.image.version="1.7.0" +LABEL org.opencontainers.image.version="1.8.0" VOLUME ["/app/data"] EXPOSE 3000 diff --git a/README.md b/README.md index 7812a6a..54f10d1 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,30 @@ You only need to configure the provider(s) you actually want to use - leave the other's ID/SECRET blank in `.env` and its login button will just fail if clicked (harmless, but you may want to hide it later). +### If you plan to enable SuperTokens later + +SuperTokens serves its OAuth callbacks at `/auth/callback/`, while +the paths above are `/auth//callback`. GitHub requires a redirect +URL's path to be a **subdirectory** of the registered callback URL, and +`/auth/callback/github` is *not* a subdirectory of `/auth/github/callback` - so +left alone, every SuperTokens GitHub login would fail with a `redirect_uri` +mismatch while passport logins carried on working. + +The fix is one-time, **additive and reversible**: widen the GitHub OAuth app's +registered callback to the parent path `https:///auth`. Both paths +are then subdirectories of it and both work simultaneously - nothing is +removed, so passport keeps working before, during and after. Discord permits +multiple redirect URIs, so simply add +`https:///auth/callback/discord` alongside the existing one. + +Leave `GITHUB_CALLBACK_URL` / `DISCORD_CALLBACK_URL` pointing at the existing +`/auth//callback` paths - those tell passport where to send people, +and passport's paths have not changed. + +**Do this before setting `AUTH_MODE`, not at the same time.** It is safe to do +days early. Full sequence in +[`docs/supertokens-rollout-runbook.md`](docs/supertokens-rollout-runbook.md). + ## 2. Configure ```bash diff --git a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md index a9a57d1..48beb44 100644 --- a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md +++ b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md @@ -238,17 +238,17 @@ The change that would otherwise break every GitHub login (spec §5.4). **Files:** - Modify: `docs/supertokens-rollout-runbook.md`, `README.md`, `.env.example` -- [ ] **Step 1: Write the GitHub instruction precisely** +- [x] **Step 1: Write the GitHub instruction precisely** GitHub's rule: the redirect URL's path must reference a **subdirectory** of the registered callback URL. SuperTokens uses `/auth/callback/`; RackStack uses `/auth//callback`. `/auth/callback/github` is *not* a subdirectory of `/auth/github/callback`, so left alone every SuperTokens GitHub login fails with `redirect_uri` mismatch. Fix: widen the registered callback to `https:///auth`. Both paths then qualify. Nothing is removed, so passport keeps working — state that explicitly, because "change your OAuth app" reads as dangerous and the operator needs to know it is additive and reversible. -- [ ] **Step 2: Discord** +- [x] **Step 2: Discord** Discord permits multiple redirect URIs; the SuperTokens one is added alongside the existing one. Nothing removed. -- [ ] **Step 3: Order the runbook correctly** +- [x] **Step 3: Order the runbook correctly** The OAuth URL change must happen **before** `AUTH_MODE=dual`, and it is safe to do days earlier. Say so — sequencing is the whole risk here. @@ -260,19 +260,19 @@ The OAuth URL change must happen **before** `AUTH_MODE=dual`, and it is safe to - Modify: `docker-compose.yml`, `unraid-template.xml`, `.env.example`, `README.md`, `CHANGELOG.md`, `Dockerfile` (version label), `package.json` (version) - Create/finish: `docs/supertokens-rollout-runbook.md` -- [ ] **Step 1: Finish the runbook** +- [x] **Step 1: Finish the runbook** Parts mirroring v1.7's: prerequisites (v1.7 in production, backup taken), OAuth widening, stand up the SuperTokens container, run shadow mode, read the gate, cut over to `dual`, verify, then optionally `supertokens`. Rollback is its own part: `AUTH_MODE=passport` + restart, JWT cookies still valid, nothing lost. -- [ ] **Step 2: State what has not been verified** +- [x] **Step 2: State what has not been verified** Explicitly, in the runbook and the PR: shadow mode has not been run against production identities, and no cutover has happened. Do not let the runbook imply otherwise — v1.7's runbook had to be corrected for exactly this. -- [ ] **Step 3: Version + changelog** +- [x] **Step 3: Version + changelog** Bump `package.json` to 1.8.0 and the Dockerfile's `org.opencontainers.image.version` label to match. Per the v1.7 correction, `client/package.json` is deliberately **not** bumped — `client/vite.config.js` reads the root `package.json` as the single version authority. Tag only after merge to main. -- [ ] **Step 4: Full verification** +- [x] **Step 4: Full verification** `npm run test:all` green on both backends, all six e2e smoke suites green, and a real boot in each of the three `AUTH_MODE` values. @@ -503,6 +503,56 @@ it keeps Task 3's "exactly two new functions" boundary intact. would not be the guarantee that matters, which is that this is safe to point at production while people are playing. +### Task 6 + +Almost entirely satisfied by the runbook Part A written during Task 2. The +remaining work was propagating the same guidance to the two other places an +operator configures OAuth — `README.md`'s "Create OAuth apps" section and the +provider blocks in `.env.example` — so that someone setting the project up +today is told about the widening before they hit it, rather than only someone +who opens the runbook. + +Both restate the three things that matter: the widening is **additive and +reversible**, `GITHUB_CALLBACK_URL`/`DISCORD_CALLBACK_URL` stay pointing at +the passport paths, and it must happen **before** `AUTH_MODE` changes. + +### Task 7 + +**`[auth] SuperTokens initialised (...)` is now logged at boot.** Not in the +plan. Found while doing Step 4's real-boot verification: all three modes came +up clean and completely silently, so there was no way — from the log an +operator actually reads — to tell a working `dual` boot from one that had +quietly not initialised SuperTokens. The line names the mode, the core URI and +the providers that came up, which are the three things that are wrong when a +rollout misbehaves. + +**Step 4's boot verification, and what it proved.** A real `node +server/index.js` in each of the three modes, each probed over HTTP rather than +just checked for a "listening" line: + +| Mode | `/auth/github` | SuperTokens | +|---|---|---| +| `passport` | 302 to github.com | not initialised ✅ | +| `dual` | 302 to github.com | initialised, `providers=github,discord` | +| `supertokens` | 200 (SPA fallback — route absent) | initialised, `providers=github,discord` | + +That is the containment guarantee and the route gating confirmed at the +process level, not just under supertest. The scratch script used for it was +not kept — it was a verification step, not a deliverable, and its result is +recorded here. + +**`client/package.json` deliberately left at 1.5.0**, per the v1.7 correction: +`client/vite.config.js` reads the root `package.json` as the single version +authority, so bumping the client's would create a second, drifting one. + +**The runbook leads with what has NOT been verified.** Five items, stated +plainly at the top rather than buried: shadow mode never run against production +identities, no cutover anywhere, v1.7 itself not yet cut over on Unraid, no +SuperTokens core ever run against this code outside tests, and +`supertokens`-only mode not recommended pending frontend refresh work. v1.7's +runbook had to be corrected for implying more had been rehearsed than had; +this one starts there instead. + ### Correction to the design Spec §5.5 called the "SuperTokens `thirdPartyUserId` equals passport's diff --git a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md index 963e30a..08a2add 100644 --- a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md +++ b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md @@ -257,14 +257,19 @@ Starts only after v1.7 is confirmed running in production. > > Implementation plan: `docs/superpowers/plans/2026-08-06-v1.8-supertokens.md`. > Operator runbook: `docs/supertokens-rollout-runbook.md`. -> Progress: Tasks 1–4 of 7 built (the `AUTH_MODE` switch; SuperTokens init, -> provider config and conditional mounting; the identity mapping and its -> ordering guarantee; the authentication chain across all three modes). +> Progress: **all 7 tasks built.** 587 tests green on SQLite, 610 on +> Postgres, 39 e2e smoke assertions, and a real boot verified in each of the +> three `AUTH_MODE` values. Version bumped to 1.8.0; not yet tagged. > -> One gap found in Task 4 and not yet closed: the client does not use the -> SuperTokens frontend SDK, so it cannot refresh an expired access token. -> This does not affect `dual` (the legacy cookie still authenticates), but a -> `supertokens`-only cutover needs frontend refresh handling first. +> One gap found in Task 4 and deliberately not closed in this release: the +> client does not use the SuperTokens frontend SDK, so it cannot refresh an +> expired access token. This does not affect `dual` (the legacy cookie still +> authenticates and the player stays logged in), but a `supertokens`-only +> cutover needs frontend refresh handling first. **`dual` is the intended +> resting state for v1.8.** +> +> Still true, and unchanged by any of the above: shadow mode has not been run +> against production identities, and no cutover has happened anywhere. ### 5.1 Containers diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index 6942982..ce137ee 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -1,11 +1,29 @@ # SuperTokens Rollout Runbook (v1.8) -**Status: IN PROGRESS — not ready to run.** The `AUTH_MODE` switch exists and -defaults to the legacy stack, so v1.8 is safe to *deploy*. It is not yet safe -to *roll out*: the SuperTokens integration behind the switch is still being -built. Parts B onward are placeholders until the tasks that back them land. - -Do not set `AUTH_MODE` to anything but blank or `passport` yet. +**Status: built, and not yet run anywhere.** Every part below is implemented +and covered by tests. `AUTH_MODE` defaults to the legacy stack, so v1.8 is safe +to deploy and changes nothing until an operator sets it. + +### What has NOT been verified + +Stated plainly, because a runbook that reads as though it has been rehearsed is +worse than one that admits it has not: + +- **Shadow mode has never run against production identities.** The owner's + current Unraid export has not been supplied. Part C is tested — including + against a database deliberately seeded with a bad row — but only ever + against test data. +- **No cutover has happened.** `AUTH_MODE` has never been anything but + `passport` on any real deployment. +- **v1.7 has not been cut over on the Unraid box either.** The design gates + v1.8's rollout on v1.7 running in production, and that is still outstanding. +- **No SuperTokens core has been run against this code outside tests.** Part B + is written from the documented configuration, not from a stood-up instance. +- **`supertokens`-only mode is not recommended yet** — see D6. `dual` is the + intended resting state for this release. + +None of that blocks *deploying* v1.8. All of it blocks *rolling it out*, and +Part C exists to close the first item. --- @@ -313,7 +331,76 @@ what the gate is meant to clear beforehand. **C2 is the gate.** ## Part D — Cutover -*Pending Tasks 3, 4 and 7. Gated on Part C reporting 100%.* +**Do not start this until Part C reports `GATE: PASS` against your real +database.** Parts A and B must also be done. + +### D1. Take a backup + +Same procedure as the Postgres migration — see +[`postgres-migration-runbook.md`](./postgres-migration-runbook.md) Part A. +Changing `AUTH_MODE` rewrites no player data, so this is belt-and-braces +rather than strictly required, but it costs minutes and the alternative to +having it is discovering you needed it. + +### D2. Switch to `dual` + +On the RackStack container, set: + +``` +AUTH_MODE=dual +``` + +Restart. Both login paths are now live and a session from either is accepted. + +**Nobody is logged out by this.** Every existing cookie is a 90-day JWT and +`dual` keeps accepting them — that is the whole point of the mode. + +### D3. Watch the boot + +The container either starts cleanly or refuses to start. If it refuses, the +message names the cause; the three common ones: + +| Message mentions | Cause | Fix | +|---|---|---| +| `Invalid AUTH_MODE` | Typo. Values are exact lowercase. | `passport`, `dual`, `supertokens` | +| `requires SUPERTOKENS_CONNECTION_URI` | Part B4 not done | Set it, restart | +| `needs to know this server's public origin` | No `PUBLIC_ORIGIN` and no callback URL to derive it from | Set `PUBLIC_ORIGIN` | + +A refusal to start is the designed behaviour for a misconfiguration, not a +failure of the rollout. Nothing has changed for players at that point — the +previous container is still what is running until the new one comes up. + +### D4. Verify, in this order + +1. **An existing session still works.** Open the game in a browser that was + already logged in. It should load your save with no login prompt at all. + This is the no-forced-logout guarantee. +2. **A legacy login still works.** Log out, then log in with the normal + Discord/GitHub button. This still goes through passport in `dual`. +3. **Your save is intact and your admin access still works.** Check the Admin + tab appears if you are in `SUPER_ADMIN_IDS`. + +If any of those fail, go to Part E. Nothing needs unpicking first. + +### D5. Sit on `dual` + +There is no schedule to keep. `dual` is a stable state, not a transition — +both stacks work, rollback stays free, and nothing degrades by leaving it +there for weeks. + +### D6. `supertokens` mode — not yet recommended + +> **Read this before considering it.** The client does not use the SuperTokens +> frontend SDK, so it has no interceptor to refresh an expired access token. In +> `dual` that is harmless: when a SuperTokens session expires, the request +> falls through to the legacy JWT cookie and the player stays logged in. In +> `supertokens` mode, once a player's legacy cookie has also expired, there is +> nothing to fall through to and they would be silently logged out when the +> access token expires. +> +> `supertokens` mode is implemented and tested, but **cutting over to it needs +> frontend refresh handling first**. `dual` is the intended resting state for +> this release. ## Part E — Rollback diff --git a/package.json b/package.json index 616a28c..0fcd8d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rackstack-server", - "version": "1.7.0", + "version": "1.8.0", "private": true, "type": "module", "scripts": { diff --git a/server/supertokens/init.js b/server/supertokens/init.js index 6c27b2e..0f5214b 100644 --- a/server/supertokens/init.js +++ b/server/supertokens/init.js @@ -102,6 +102,15 @@ export async function initSuperTokens({ env = process.env, mode } = {}) { }); initialised = true; + // An operator who has just flipped AUTH_MODE needs to see that it took + // effect, and needs to see it in the log rather than by inferring it from + // the absence of an error. Names the mode, the core it will talk to, and + // which providers came up - the three things that are wrong when a rollout + // is not behaving. + console.log( + `[auth] SuperTokens initialised (AUTH_MODE=${mode}, core=${connectionURI}, ` + + `providers=${providers.map((p) => p.config.thirdPartyId).join(',')})`, + ); return true; } From be682751c491c7e509db5434168eaf745bab7fce Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 19:01:41 -0400 Subject: [PATCH 09/14] Pin the SDK surface the mapping override depends on The mapping override is tested against a fake core, which is what makes its ordering assertions possible - but a fake answers to any method name, so nothing there would notice if the real SDK renamed or dropped these. These two tests are the only check that the object init.js hands to buildSignInUpOverride actually carries the functions it will call, and that the capital-T parameter spelling is still what the SDK reads. Worth pinning because of how it fails otherwise: an absent or misspelled createUserIdMapping means no mapping is created, and no mapping means a returning player silently lands on an empty save, with no error at the moment it happens. Verified against the installed supertokens-node 24.0.3 before writing them. 589 tests on SQLite, 612 on Postgres. Co-Authored-By: Claude Opus 5 --- tests/supertokens.init.test.js | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/supertokens.init.test.js b/tests/supertokens.init.test.js index 792807d..bf7bc8b 100644 --- a/tests/supertokens.init.test.js +++ b/tests/supertokens.init.test.js @@ -174,6 +174,38 @@ describe('the SDK actually loads on this runtime', () => { expect(typeof middleware).toBe('function'); expect(typeof errorHandler).toBe('function'); }); + + it('exposes the two user-id-mapping functions the signInUp override calls', async () => { + // The mapping override is tested against a fake core, which is what makes + // its ordering assertions possible - but a fake will happily answer to any + // method name, so nothing there would notice if the real SDK renamed or + // dropped these. This is the only check that the object init.js actually + // hands to buildSignInUpOverride carries the functions it will call. + // + // Worth pinning precisely because of how it would fail otherwise: an + // absent createUserIdMapping means no mapping is created, and no mapping + // means a returning player silently lands on an empty save. There is no + // error at the moment it happens. + const core = await import('supertokens-node').then((m) => m.default ?? m); + expect(typeof core.createUserIdMapping).toBe('function'); + expect(typeof core.getUserIdMapping).toBe('function'); + }); + + it("takes the mapping parameter spelled with a capital T, as mapping.js sends it", async () => { + // `superTokensUserId`, not `supertokensUserId`. The lowercase spelling is + // accepted silently as undefined - no throw, no log, no mapping - so the + // typo fails as the invisible wrong-save bug rather than as an error. + // Asserted against the shipped type declaration, which is the only + // machine-readable statement of the key the SDK reads. + const { readFileSync } = await import('node:fs'); + const dts = readFileSync( + new URL('../node_modules/supertokens-node/lib/build/index.d.ts', import.meta.url), + 'utf8', + ); + const signature = dts.slice(dts.indexOf('static createUserIdMapping')); + expect(signature).toContain('superTokensUserId'); + expect(signature.slice(0, 200)).not.toContain('supertokensUserId:'); + }); }); describe('initSuperTokens configuration errors', () => { From 671130dc6a7b2be7f00e7f921f395d4109a423dd Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 19:17:57 -0400 Subject: [PATCH 10/14] Correct two stale status lines in the v1.8 docs The runbook's status table still listed Task 7 as not started, though the deployment docs and the 1.8.0 version bump landed in 340ccc9. The spec's progress block cited 587/610 tests, from before be68275 added two. Measured now: sqlite 589 passed/26 skipped, postgres 612 passed/3 skipped, 39 e2e smoke assertions with zero errors. Documentation-only; no behaviour change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS --- .../superpowers/specs/2026-08-01-postgres-supertokens-design.md | 2 +- docs/supertokens-rollout-runbook.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md index 08a2add..37bc979 100644 --- a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md +++ b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md @@ -257,7 +257,7 @@ Starts only after v1.7 is confirmed running in production. > > Implementation plan: `docs/superpowers/plans/2026-08-06-v1.8-supertokens.md`. > Operator runbook: `docs/supertokens-rollout-runbook.md`. -> Progress: **all 7 tasks built.** 587 tests green on SQLite, 610 on +> Progress: **all 7 tasks built.** 589 tests green on SQLite, 612 on > Postgres, 39 e2e smoke assertions, and a real boot verified in each of the > three `AUTH_MODE` values. Version bumped to 1.8.0; not yet tagged. > diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index ce137ee..13f564e 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -37,7 +37,7 @@ Part C exists to close the first item. | Auth middleware chain | 4 | ✅ built | | Shadow-mode verification | 5 | ✅ built — **not yet run against production** | | OAuth callback URL changes | 6 | 📄 documented below, not yet needed | -| Deployment config + release | 7 | ⬜ not started | +| Deployment config + release | 7 | ✅ built — version at 1.8.0, not yet tagged | Plan: [`superpowers/plans/2026-08-06-v1.8-supertokens.md`](./superpowers/plans/2026-08-06-v1.8-supertokens.md) Design: [`superpowers/specs/2026-08-01-postgres-supertokens-design.md`](./superpowers/specs/2026-08-01-postgres-supertokens-design.md) §5 From 65778bcfd4f79c8297c229852781594aad34b68a Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 19:28:50 -0400 Subject: [PATCH 11/14] Fix an authentication bypass and a Discord outage found by security review Both live in the seam between our configuration and supertokens-node's defaults, which is where nothing else was looking. HIGH - authentication bypass, potential account takeover. SuperTokens' stock signInUpPOST accepts EITHER redirectURIInfo (the browser authorization-code flow) OR a caller-supplied oAuthTokens object, and treats a submitted token as proof of identity: recipe/thirdparty/api/signinup.js else if (bodyParams.oAuthTokens !== undefined) { oAuthTokens = ... } The audience check that should make that safe is dead code for GitHub in the pinned SDK. providers/github.js DEFINES config.validateAccessToken - which asks api.github.com/applications/{client_id}/token whether the token was minted for this app - but that is only ever invoked from the GENERIC getUserInfo in providers/custom.js, and github.js then REPLACES getUserInfo wholesale in an override applied last. Its replacement calls api.github.com/user with `Bearer ` and asks nothing about the token's origin. Verified by reading both files in node_modules, not inferred. Unpatched, an unauthenticated POST /auth/signinup {"thirdPartyId":"github","oAuthTokens":{"access_token":"..."}} carrying ANY GitHub token able to read /user - one minted for an unrelated OAuth app the victim authorised, or a leaked PAT - resolves to that victim's thirdPartyUserId. Our mapping then faithfully turns it into their users.id and issues a session. SUPER_ADMIN_IDS values are deterministic and effectively public (github:37058311), so the owner's account is the obvious target, and that path reaches every admin route. This is a regression against the passport stack rather than a pre-existing flaw: passport-github2 only ever obtains a token by exchanging an authorization code with our own client secret, so a foreign token cannot be replayed at it. Fixed with an `apis` override rejecting the token-submission flow entirely. RackStack is browser-only and has no native client, so that flow has no legitimate caller here; the redirect flow it keeps obtains its token via our own client secret and is therefore bound to this application. Never exploitable in production: it exists only in dual/supertokens mode, and AUTH_MODE has never been anything but passport anywhere. It would have gone live the moment the owner followed Part D of the runbook. CORRECTNESS - every Discord login would have failed. Self-inflicted, in Task 2. We pin Discord to scope ['identify'] to match passport-discord, so returning players are not re-prompted to consent to a new permission mid-rollout. But SuperTokens' API layer substitutes a placeholder email only when requireEmail === false, and otherwise returns NO_EMAIL_GIVEN_BY_PROVIDER - and Discord's built-in provider does not set it. The two choices combined into a total Discord outage that fails in the API layer, before the mapping override runs, where none of our own code or tests would have seen it. Fixed by setting requireEmail: false on the Discord provider, keeping the narrow scope. Safe because RackStack never uses email: identity is provider:providerId end to end and upsertUser takes only provider, providerId, username and avatarUrl. 9 regression tests in tests/supertokens.security.test.js. Verified discriminating: with both fixes reverted, 4 fail, including the wiring check that the guard is actually installed in ThirdParty.init - a unit test of the function alone cannot see that half. Recorded in the runbook (which now leads with the review outcome) and in the spec's risk register. sqlite 598 passed/26 skipped, postgres 621 passed/3 skipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS --- .../2026-08-01-postgres-supertokens-design.md | 1 + docs/supertokens-rollout-runbook.md | 39 +++++ server/supertokens/init.js | 77 +++++++++- server/supertokens/providers.js | 14 ++ tests/supertokens.security.test.js | 139 ++++++++++++++++++ 5 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 tests/supertokens.security.test.js diff --git a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md index 37bc979..aac7742 100644 --- a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md +++ b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md @@ -399,6 +399,7 @@ full 90-day expiry, so in-flight sessions survive the round trip. | SQLite driver rots untested | Full suite runs on both backends in CI | | SuperTokens id shape differs from stored `provider_id` | Shadow mode against production export; cutover gated on 100% match | | SuperTokens session carries the wrong user id | Mapping created before session issuance; asserted by test | +| **Stock `signInUpPOST` accepts a submitted OAuth token as proof of identity** | **Found by security review 2026-08-06.** The SDK's GitHub `validateAccessToken` audience check is dead code (the provider replaces `getUserInfo`, and only the generic one calls it), so any GitHub token able to read `/user` would authenticate as its owner — account takeover, and full admin for a `SUPER_ADMIN_IDS` holder. Fixed by an `apis` override rejecting the `oAuthTokens` flow; RackStack is browser-only so it has no legitimate caller. Regression-tested. | | GitHub redirect_uri mismatch | Registered callback widened to `/auth` before enabling `dual` | | Async refactor swallows route errors | Every handler audited for try/catch during the refactor | diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index 13f564e..250cf4c 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -4,6 +4,45 @@ and covered by tests. `AUTH_MODE` defaults to the legacy stack, so v1.8 is safe to deploy and changes nothing until an operator sets it. +### Security review (2026-08-06) + +A security review of the branch was run before any cutover. It found **one +High-severity authentication bypass**, now fixed and regression-tested. + +**What it was.** SuperTokens' stock `POST /auth/signinup` accepts *either* the +browser redirect flow *or* a caller-supplied `oAuthTokens` object, and treats a +submitted token as proof of identity. The audience check that should make that +safe is dead code for GitHub in the pinned SDK: the provider defines +`validateAccessToken` but then replaces `getUserInfo` wholesale, and only the +generic `getUserInfo` ever calls it. + +Unpatched, an unauthenticated request carrying **any** GitHub token able to +read `/user` — one minted for an unrelated OAuth app the victim had authorised, +or a leaked personal access token — would have authenticated as that token's +owner. `SUPER_ADMIN_IDS` values are deterministic and effectively public, so +the owner's own account was the obvious target, and that path led to every +admin route. + +This was a regression against the passport stack, not a pre-existing hole: +passport only ever obtains a token by exchanging an authorization code with our +own client secret, so a foreign token can never be replayed at it. + +**Fix.** RackStack is browser-only and has no native client, so the +token-submission flow has no legitimate caller. It is now rejected outright; +only the redirect flow is accepted. Seven regression tests cover it, and the +guard was verified to fail closed when removed. + +**It was never exploitable in production**, because it only exists in +`dual`/`supertokens` mode and `AUTH_MODE` has never been anything but +`passport` anywhere. It would have become live the moment you followed Part D. + +The same review also caught a **Discord outage**, unrelated to security: we +pin Discord to the `identify` scope to avoid re-prompting existing players for +a new permission, but SuperTokens rejects a provider that returns no email +unless `requireEmail: false` is set — which Discord's built-in provider does +not. Every Discord login would have failed at the API layer, before any of our +own code ran. Fixed and tested. + ### What has NOT been verified Stated plainly, because a runbook that reads as though it has been rehearsed is diff --git a/server/supertokens/init.js b/server/supertokens/init.js index 0f5214b..5925dba 100644 --- a/server/supertokens/init.js +++ b/server/supertokens/init.js @@ -32,6 +32,63 @@ export const API_BASE_PATH = '/auth'; let initialised = false; +/** + * Closes an authentication bypass in SuperTokens' stock `signInUpPOST`. + * + * THE HOLE. The stock API accepts EITHER `redirectURIInfo` (the browser + * authorization-code flow) OR a caller-supplied `oAuthTokens` object, and + * treats the latter as proof of identity: + * + * recipe/thirdparty/api/signinup.js + * else if (bodyParams.oAuthTokens !== undefined) { oAuthTokens = ... } + * + * The audience check that should make that safe does not run for GitHub. The + * SDK's GitHub provider DEFINES `config.validateAccessToken` - which asks + * `POST api.github.com/applications/{client_id}/token` whether the token was + * minted for this OAuth app - but that function is only ever invoked from the + * GENERIC `getUserInfo` in providers/custom.js. providers/github.js then + * REPLACES `getUserInfo` wholesale in its own override, and the override is + * applied last, so the check is dead code. GitHub's replacement calls + * api.github.com/user directly with `Bearer ` and asks nothing about + * where the token came from. + * + * Net effect, unpatched: an unauthenticated + * POST /auth/signinup {"thirdPartyId":"github","oAuthTokens":{"access_token":"..."}} + * with ANY GitHub token that can read /user - one from an unrelated OAuth app + * the victim authorised, or a leaked PAT - resolves to that victim's + * `thirdPartyUserId`, and our mapping faithfully turns it into their + * `users.id`. Account takeover, and full admin if the victim is in + * SUPER_ADMIN_IDS, whose values are deterministic and effectively public. + * + * This is a regression against the passport stack rather than a pre-existing + * flaw: passport-github2 only ever obtains a token by exchanging an + * authorization code with our own client secret, so a foreign token can never + * be replayed at it. + * + * THE FIX. RackStack is browser-only and has no native or mobile client, so + * the token-submission flow has no legitimate caller here. Reject it and keep + * only the redirect flow, where the token is obtained by exchanging a code + * using our own client secret and is therefore bound to this application. + */ +export function rejectRawOAuthTokens(originalImplementation) { + return { + ...originalImplementation, + signInUpPOST: originalImplementation.signInUpPOST === undefined + ? undefined + : async function signInUpPOST(input) { + if (input.redirectURIInfo === undefined) { + throw new Error( + 'signInUp requires the redirect-URI flow. Submitting oAuthTokens directly is ' + + 'not accepted: RackStack cannot verify that such a token was issued to this ' + + 'application, so honouring it would let any third-party token authenticate as ' + + 'its owner.', + ); + } + return originalImplementation.signInUpPOST(input); + }, + }; +} + /** * Initialises SuperTokens if the mode calls for it. * @@ -89,13 +146,19 @@ export async function initSuperTokens({ env = process.env, mode } = {}) { recipeList: [ ThirdParty.init({ signInUpFeature: { providers }, - // The override is on `functions` (the recipe function), NOT `apis`. - // SuperTokens creates the session in the API layer after the recipe - // function returns, so an `apis` override would run too late to get - // the user id mapping in place first - and a session carrying - // SuperTokens' internal id resolves to no save at all. See - // ./mapping.js and design section 5.3. - override: { functions: buildSignInUpOverride({ supertokens }) }, + override: { + // The IDENTITY MAPPING override is on `functions` (the recipe + // function), NOT `apis`. SuperTokens creates the session in the API + // layer after the recipe function returns, so putting it in `apis` + // would run too late to get the user id mapping in place first - + // and a session carrying SuperTokens' internal id resolves to no + // save at all. See ./mapping.js and design section 5.3. + functions: buildSignInUpOverride({ supertokens }), + // The `apis` override exists for a different reason entirely: to + // close an authentication bypass in the stock signInUpPOST. See + // rejectRawOAuthTokens below. + apis: rejectRawOAuthTokens, + }, }), Session.init(), ], diff --git a/server/supertokens/providers.js b/server/supertokens/providers.js index 37fd370..f032b6d 100644 --- a/server/supertokens/providers.js +++ b/server/supertokens/providers.js @@ -63,6 +63,20 @@ export function buildProviders(env = process.env) { providers.push({ config: { thirdPartyId: 'discord', + // REQUIRED because of the narrowed scope below, not optional polish. + // SuperTokens' API layer treats a provider that returns no email as a + // failure - recipe/thirdparty/api/implementation.js only substitutes a + // placeholder when `requireEmail === false`, and otherwise returns + // status NO_EMAIL_GIVEN_BY_PROVIDER. Discord's built-in provider does + // not set it. Without this line, dropping the 'email' scope means + // every Discord login fails before the mapping override is ever + // reached, and it fails at the API layer where our own code never + // sees it. + // + // Safe here because RackStack never uses email for anything: identity + // is `provider:providerId` end to end, and upsertUser takes only + // provider, providerId, username and avatarUrl. + requireEmail: false, clients: [{ clientId: env.DISCORD_CLIENT_ID, clientSecret: env.DISCORD_CLIENT_SECRET, diff --git a/tests/supertokens.security.test.js b/tests/supertokens.security.test.js new file mode 100644 index 0000000..d7cb8a3 --- /dev/null +++ b/tests/supertokens.security.test.js @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { rejectRawOAuthTokens } from '../server/supertokens/init.js'; +import { buildProviders } from '../server/supertokens/providers.js'; + +// Regressions for two defects found by a security review of the v1.8 branch, +// before any cutover. Both live in the seam between our configuration and +// supertokens-node's own defaults, which is exactly where nothing else looks. + +describe('rejectRawOAuthTokens (authentication bypass guard)', () => { + // THE HOLE. SuperTokens' stock signInUpPOST accepts EITHER redirectURIInfo + // (the browser authorization-code flow) OR a caller-supplied oAuthTokens + // object, and treats the latter as proof of identity: + // + // recipe/thirdparty/api/signinup.js + // else if (bodyParams.oAuthTokens !== undefined) { oAuthTokens = ... } + // + // For GitHub the audience check that would make that safe is dead code. + // providers/github.js DEFINES config.validateAccessToken - which asks + // api.github.com whether the token was minted for this OAuth app - but that + // is only ever invoked from the GENERIC getUserInfo in providers/custom.js, + // and github.js then REPLACES getUserInfo wholesale in an override applied + // last. Its replacement calls api.github.com/user with `Bearer ` and + // asks nothing about the token's origin. + // + // Unpatched, an unauthenticated + // POST /auth/signinup {"thirdPartyId":"github","oAuthTokens":{...}} + // carrying ANY GitHub token able to read /user - one minted for an unrelated + // OAuth app the victim authorised, or a leaked PAT - resolves to that + // victim's thirdPartyUserId, and our mapping faithfully turns it into their + // users.id. Account takeover; full admin when the victim is in + // SUPER_ADMIN_IDS, whose values are deterministic and effectively public. + // + // A regression against the passport stack rather than a pre-existing flaw: + // passport-github2 only ever obtains a token by exchanging an authorization + // code with our own client secret, so a foreign token cannot be replayed. + // + // Asserted against the guard directly rather than through a live core, + // because the behaviour being guarded lives inside the SDK. + + const passThrough = { signInUpPOST: async (input) => ({ status: 'OK', echoed: input }) }; + + it('rejects a request that submits raw oAuthTokens', async () => { + const guarded = rejectRawOAuthTokens(passThrough); + await expect(guarded.signInUpPOST({ + oAuthTokens: { access_token: 'gho_stolen_from_another_app' }, + })).rejects.toThrow(/redirect-URI flow/); + }); + + it('names why, not merely that it refused', async () => { + const guarded = rejectRawOAuthTokens(passThrough); + await expect(guarded.signInUpPOST({ oAuthTokens: { access_token: 'x' } })) + .rejects.toThrow(/issued to this application/); + }); + + it('lets the legitimate redirect-URI flow through untouched', async () => { + // The guard must not break real logins. In this flow the token is obtained + // by exchanging an authorization code with our own client secret, so it is + // bound to this application and there is nothing to replay. + const guarded = rejectRawOAuthTokens(passThrough); + const info = { redirectURIOnProviderDashboard: 'https://x.example.com/auth/callback/github' }; + await expect(guarded.signInUpPOST({ redirectURIInfo: info })) + .resolves.toMatchObject({ status: 'OK' }); + }); + + it('takes the safe path when a request carries both', async () => { + // The guard keys on the presence of redirectURIInfo, so a request + // supplying both still goes through the redirect flow. Asserted because + // the SDK's own precedence between the two is an implementation detail we + // should not be depending on silently. + const guarded = rejectRawOAuthTokens(passThrough); + const info = { redirectURIOnProviderDashboard: 'https://x.example.com/cb' }; + await expect(guarded.signInUpPOST({ + redirectURIInfo: info, + oAuthTokens: { access_token: 'x' }, + })).resolves.toMatchObject({ status: 'OK' }); + }); + + it('preserves the other apis the SDK exposes', async () => { + // An override that dropped its siblings would silently disable + // authorisationurl and the Apple redirect handler. + const original = { + signInUpPOST: async () => ({ status: 'OK' }), + authorisationUrlGET: async () => ({ status: 'OK', urlWithQueryParams: 'https://x' }), + appleRedirectHandlerPOST: async () => ({ status: 'OK' }), + }; + const guarded = rejectRawOAuthTokens(original); + expect(typeof guarded.authorisationUrlGET).toBe('function'); + expect(typeof guarded.appleRedirectHandlerPOST).toBe('function'); + await expect(guarded.authorisationUrlGET()).resolves.toMatchObject({ status: 'OK' }); + }); + + it('tolerates the api being disabled entirely', async () => { + // SuperTokens lets an api be set to undefined to switch it off. Wrapping + // that in a function would resurrect a deliberately disabled endpoint. + const guarded = rejectRawOAuthTokens({ signInUpPOST: undefined }); + expect(guarded.signInUpPOST).toBeUndefined(); + }); + + it('is actually wired into ThirdParty.init', () => { + // The guard is worthless if it is never installed, and the wiring is the + // half a unit test of the function cannot see. Source-level because + // asserting it live would need a running core. + const src = readFileSync(new URL('../server/supertokens/init.js', import.meta.url), 'utf8'); + expect(src).toMatch(/apis:\s*rejectRawOAuthTokens/); + }); +}); + +describe('Discord logins must be able to complete at all', () => { + it('sets requireEmail:false, because the pinned scope returns no email', () => { + // Not polish - without this, every Discord login fails. + // + // We deliberately pin Discord to scope ['identify'] to match what + // passport-discord already requested, so returning players are not + // re-prompted to consent to a new permission mid-rollout. But SuperTokens' + // API layer (recipe/thirdparty/api/implementation.js) substitutes a + // placeholder email only when requireEmail === false; otherwise it returns + // NO_EMAIL_GIVEN_BY_PROVIDER. Discord's built-in provider does not set it. + // + // So the two choices combine into a total Discord outage, and it fails in + // the API layer - before the mapping override runs, where none of our own + // code or tests would see it. + // + // Safe because RackStack never uses email: identity is + // `provider:providerId` end to end, and upsertUser takes only provider, + // providerId, username and avatarUrl. + const [dc] = buildProviders({ DISCORD_CLIENT_ID: 'a', DISCORD_CLIENT_SECRET: 'b' }); + expect(dc.config.clients[0].scope).toEqual(['identify']); + expect(dc.config.requireEmail).toBe(false); + }); + + it('leaves GitHub on its default scope, which does yield an email', () => { + // GitHub is not pinned, so it keeps ['read:user','user:email'] and returns + // an email - hence no requireEmail override is needed or wanted there. + const [gh] = buildProviders({ GITHUB_CLIENT_ID: 'a', GITHUB_CLIENT_SECRET: 'b' }); + expect(gh.config.clients[0].scope).toBeUndefined(); + expect(gh.config.requireEmail).toBeUndefined(); + }); +}); From 8a1f224d5187c2c1de6031459ba81d9a1e9a67d9 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 23:25:05 -0400 Subject: [PATCH 12/14] Add the auth migration and extension guide; correct a understated blocker docs/authentication-methods.md covers two things: the full migration path from today's passport Discord/GitHub to SuperTokens, phase by phase with the gate between each; and how to add further login methods afterwards. Writing it surfaced a gap the earlier docs understated. They said supertokens-only mode was "not recommended pending frontend refresh work". The real blocker is larger: client/src/Login.jsx hardcodes its buttons to /auth/discord and /auth/github - the passport routes, which supertokens mode deliberately does not register. In that mode the buttons fall through to the SPA and silently do nothing, so NOBODY CAN LOG IN. Existing sessions keep working through the JWT fallback, which is precisely what makes it easy to miss: the app looks fine until someone tries to sign in. The server side is complete - the middleware serves /auth/authorisationurl and /auth/signinup and the mapping resolves correctly - so this is un-started frontend work rather than a defect. But supertokens mode is unusable, not merely inadvisable, and the runbook, changelog, design spec and plan have all been corrected to say so. A related nuance now stated everywhere: because the client still logs in through passport, enabling dual does NOT route live traffic through SuperTokens. It makes those sessions acceptable and stands the stack up to be exercised deliberately. A quiet dual deployment is not evidence the SuperTokens login path works end to end. The extension half covers: deciding the account-linking question before shipping a third provider (and why auto-linking on email is an account takeover vector); adding an OAuth provider, including the requireEmail trap that would otherwise fail every login at the API layer; why non-OAuth methods need SuperTokens' own user id rather than an email as providerId, since users.id is referenced by three foreign keys; and that adding any email-bearing recipe makes the accepted nodemailer advisory live again. 598 tests green on SQLite, 621 on Postgres. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 +- docs/authentication-methods.md | 409 ++++++++++++++++++ .../plans/2026-08-06-v1.8-supertokens.md | 17 + .../2026-08-01-postgres-supertokens-design.md | 23 +- docs/supertokens-rollout-runbook.md | 51 ++- 5 files changed, 489 insertions(+), 23 deletions(-) create mode 100644 docs/authentication-methods.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c80026e..9ac8693 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,10 +40,14 @@ Everything below is inert until an operator opts in. keeps working), standing up the core, the shadow gate, cutover and rollback. **Not yet run anywhere.** Shadow mode has not been run against production -identities, and no cutover has happened. `dual` is the intended resting state -for this release: `supertokens`-only mode is implemented and tested but needs -frontend token-refresh handling before it is cut over to. The runbook says all -of this in its opening section. +identities, and no cutover has happened. + +**`dual` is the intended resting state for this release.** The server side of +`supertokens`-only mode is complete and tested, but the client has never been +taught to talk to SuperTokens: the login buttons point at the passport routes, +which that mode does not register, so logins would silently do nothing — and +there is no token refresh. Both are frontend work that has not been started. +The runbook and `docs/authentication-methods.md` cover this in full. ## v1.7.0 diff --git a/docs/authentication-methods.md b/docs/authentication-methods.md new file mode 100644 index 0000000..4f8281b --- /dev/null +++ b/docs/authentication-methods.md @@ -0,0 +1,409 @@ +# Authentication: migration plan and how to add more login methods + +Two things live here: + +1. **[Part 1](#part-1--the-migration-plan)** — the full path from where RackStack + is today (Discord + GitHub via passport) to SuperTokens, with the gates + between each phase and an honest account of what is not built yet. +2. **[Part 2](#part-2--adding-more-authentication-methods)** — how to add + further login methods once you are there, from "another OAuth provider" + (genuinely small) to "email and password" (not small, and it changes the + identity model). + +Companion documents: the operator runbook is +[`supertokens-rollout-runbook.md`](./supertokens-rollout-runbook.md); the +design is [`superpowers/specs/2026-08-01-postgres-supertokens-design.md`](./superpowers/specs/2026-08-01-postgres-supertokens-design.md). + +--- + +## The identity model — read this first + +Everything in both parts is constrained by one decision made in v1.0 and never +revisited: + +``` +users.id === `${provider}:${providerId}` e.g. github:37058311 +``` + +That string is the primary key of `users`. It is the target of three foreign +keys (`saves.user_id`, `event_participation.user_id`, `identities.user_id`), +and it is the literal value an operator puts in `SUPER_ADMIN_IDS` to grant +themselves admin. + +Since v1.7 the login methods themselves live in a separate table: + +```sql +identities + PRIMARY KEY (provider, provider_id) + user_id → users(id) ON DELETE CASCADE + supertokens_user_id TEXT UNIQUE NULL + created_at, last_login_at +``` + +**This split is what makes new login methods cheap.** A `users` row can already +have many `identities` rows pointing at it — the schema has permitted it since +v1.7. What does not exist is any code that *creates* a second one for the same +user, or any UI to trigger it. That is the account-linking question, and Part 2 +cannot avoid it. + +Three consequences worth internalising: + +- **A new provider means new accounts, not new logins for old accounts.** By + default, a player who has always used GitHub and then clicks "Continue with + Google" becomes `google:1234...` — a different `users.id`, therefore a + different save. This is not a bug; it is what `users.id` means. It is also + the single most likely thing to upset people, so decide about linking + *before* you ship a third provider, not after. +- **`provider` values are permanent.** `identities.provider` is half a primary + key and is embedded in every `users.id`. Renaming `github` to `gh` later + would orphan every save. Choose the string once. +- **Provider ids must be treated as opaque strings.** Never parse them, and + never build `users.id` from anything a user can choose freely. Today's ids + are a GitHub numeric id and a Discord snowflake — both provider-assigned and + immutable. See [the username trap](#the-username-trap) in Part 2. + +--- + +# Part 1 — The migration plan + +## Where things actually stand + +| Layer | State | +|---|---| +| `AUTH_MODE` switch, validation, containment | ✅ built, tested | +| SuperTokens init, provider config, mounting | ✅ built, tested | +| Identity mapping (`signInUp` override) | ✅ built, tested, mutation-verified | +| Auth chain (SuperTokens → JWT → 401) | ✅ built, tested in all three modes | +| Shadow-mode gate (`npm run shadow:check`) | ✅ built, tested — **never run against production** | +| `oAuthTokens` bypass fix | ✅ built, tested, mutation-verified | +| **Client-side SuperTokens login flow** | ❌ **does not exist** | +| **Client-side session refresh** | ❌ **does not exist** | +| Account linking | ❌ out of scope, by design | + +The server side of the rollout is complete. The **client side has not been +started**, and that is what bounds how far the rollout can go — see Phase 4. + +## Phase 0 — Prerequisites (not yet met) + +1. **v1.7 running in production on Postgres.** Still outstanding; the Unraid + box has not been cut over. See + [`postgres-migration-runbook.md`](./postgres-migration-runbook.md). +2. **A current production export supplied**, for the shadow gate. The copy in + `~/Downloads` is a stale July v1.1-era file (users + saves only, 4 rows + each) and is not usable for this. +3. **A backup**, taken the same way as for the Postgres migration. + +**Gate:** all three, or nothing below happens. + +## Phase 1 — Widen the OAuth redirect URLs + +Additive and reversible; nothing is removed and passport keeps working. Safe to +do days early, and it must happen **before** `AUTH_MODE` changes. + +- **GitHub** — widen the registered Authorization callback URL from + `https:///auth/github/callback` to the parent path + `https:///auth`. Both `/auth/github/callback` (passport) and + `/auth/callback/github` (SuperTokens) are then subdirectories of it and both + work simultaneously. Without this, every SuperTokens GitHub login fails with + a `redirect_uri` mismatch while passport logins carry on — which looks like + SuperTokens being broken rather than an OAuth app being misconfigured. +- **Discord** — *add* `https:///auth/callback/discord` alongside the + existing redirect. Discord permits several. Keep both. + +Leave `GITHUB_CALLBACK_URL` / `DISCORD_CALLBACK_URL` pointing at the passport +paths. They tell passport where to send people and passport has not moved. + +**Gate:** log in with both providers. Both must still work — at this point +nothing about RackStack has changed, only what the providers will accept. + +## Phase 2 — Stand up the SuperTokens core + +Inert while `AUTH_MODE` is blank; RackStack never contacts it. + +- Give it **its own database** on the existing Postgres server — not a schema + inside `rackstack`. `docker/init-supertokens-db.sql` does this on a *fresh* + `pgdata` only; any install that already migrated in v1.7 needs the + `CREATE DATABASE supertokens` run by hand. +- `POSTGRESQL_CONNECTION_URI` must use the **`postgresql://`** scheme (the core + rejects `postgres://` — this is specific to the core; RackStack's own + `DATABASE_URL` accepts either) and must not use `localhost` from inside a + container. +- Set `SUPERTOKENS_CONNECTION_URI` on RackStack but **leave `AUTH_MODE` blank.** + +**Gate:** `curl http://127.0.0.1:3567/hello` returns `Hello`. + +## Phase 3 — The shadow gate + +```bash +npm run shadow:check +``` + +Read-only; safe against production with players online, and against a restored +export on a laptop. It audits every `identities` row and asks the one question +that cannot be answered by reading library source: does `user_id` equal +`provider:provider_id` for every row actually stored? + +**Gate: `GATE: PASS` (exit 0).** `GATE: FAIL` names each offending pair — stop +and investigate per row. `GATE: NOT RUN` means it compared nothing, usually the +wrong database; that is deliberately not a pass. + +## Phase 4 — `AUTH_MODE=dual` + +Set it, restart. Both stacks live, sessions from either accepted. + +**Nobody is logged out.** Existing cookies are 90-day JWTs and `dual` keeps +accepting them. + +Verify in this order: an already-open session still works without a login +prompt; a fresh login through the normal button still works; your save and +admin access are intact. + +> **`dual` is the intended resting state for v1.8.** There is no schedule to +> keep. Both stacks work, rollback stays free, and nothing degrades by leaving +> it there indefinitely. + +An important nuance about what `dual` actually exercises: because the client +still drives logins through the passport routes (see Phase 5), turning on +`dual` does **not** by itself start routing anyone through SuperTokens. It +makes SuperTokens sessions *acceptable*, and it stands the whole stack up so it +can be exercised deliberately — it does not migrate live traffic. That is a +feature for a first cutover, but do not mistake a quiet `dual` deployment for +evidence that the SuperTokens path works end to end. + +## Phase 5 — `supertokens` mode — blocked on client work + +**This is the honest state: `supertokens`-only mode cannot be used yet, and the +blocker is larger than "not recommended".** + +The server side is complete — SuperTokens' middleware serves +`GET /auth/authorisationurl` and `POST /auth/signinup`, the mapping override +runs, and sessions resolve to the right `users.id`. The client has never been +taught to call any of it: + +1. **No login flow.** `client/src/Login.jsx` hardcodes + `` and `` — the *passport* + routes. In `supertokens` mode those routes are not registered, so the + request falls through to the SPA and the button silently does nothing. + Existing sessions keep working via the JWT fallback, but **no one can log + in**. Building this means: fetch the authorisation URL for the chosen + provider, redirect the browser to it, then hand the returned code to + `POST /auth/signinup` with `redirectURIInfo` (note: the raw-`oAuthTokens` + form is deliberately rejected — see below). +2. **No session refresh.** There is no SuperTokens frontend SDK and so no + interceptor to refresh an expired access token. In `dual` this is invisible + because the legacy cookie still authenticates; in `supertokens`-only mode, + once a player's legacy cookie has also expired, they are silently logged out + when the access token expires. + +Both are frontend work of a size worth planning separately. Until they exist, +Phase 5 is not reachable, and the runbook should not be read as implying +otherwise. + +## Phase 6 — Rollback (available at every phase) + +``` +AUTH_MODE=passport (or blank) → restart +``` + +Legacy cookies remain valid for their full 90 days, so a rollback days later +costs nothing and logs nobody out. Unlike the Postgres migration there is no +one-way door — changing `AUTH_MODE` rewrites no player data. + +**Do not change `JWT_SECRET` during any of this.** It logs out every player and +looks exactly like the auth rollout having gone wrong. + +--- + +# Part 2 — Adding more authentication methods + +## Decide the linking question first + +Before adding any third method, answer this, because retrofitting is much worse +than choosing: + +> When a player who already has an account signs in with a **new** method, do +> they get their existing save, or a new empty one? + +**Option A — separate accounts (today's behaviour).** Each provider is its own +player. Zero new code. Already true for Discord vs GitHub: they have always +been two different players with two different saves. + +**Option B — account linking.** One `users.id`, many `identities` rows. The +schema already supports it. Needs: a "link another login method" flow for a +signed-in user, a rule for what happens when someone signs in with an unlinked +method that shares an email, and a decision about merging two saves that both +already have progress. + +**Recommendation: A for OAuth providers you add now, and treat B as its own +release.** Option B's genuinely hard part is not the schema — it is that two +accounts can both have real progress, and merging them is a game-design +question (whose wafers? whose upgrades? whose achievements?), not a database +one. Deciding that under time pressure because you already shipped Google is +the bad version of this. + +If you do choose B, note the trap: **automatic linking by email address is an +account-takeover vector** unless every provider involved verifies email +ownership. An attacker who can create an account at a provider that does not +verify email, using the victim's address, would inherit the victim's save. Link +only on an explicit, authenticated action by the already-signed-in user. + +## Tier 1 — Another OAuth provider (Google, Twitch, GitLab, Apple…) + +This is the easy path: roughly 20 lines plus config. SuperTokens ships built-in +providers for the common ones. + +**1. Choose the `thirdPartyId` and never change it.** It becomes the `provider` +half of the primary key and the prefix of every `users.id` for those players. +Use SuperTokens' built-in id (`google`, `twitch`, `gitlab`, `apple`, …) so the +built-in provider config applies. + +**2. Add it to `PROVIDER_IDS` and `buildProviders`** in +`server/supertokens/providers.js`: + +```js +export const PROVIDER_IDS = Object.freeze(['github', 'discord', 'google']); + +// inside buildProviders(env): +if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { + providers.push({ + config: { + thirdPartyId: 'google', + clients: [{ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + // Only if you narrow the default scope AND the narrowed set no + // longer returns an email — see the requireEmail note below. + }], + }, + }); +} +``` + +Follow the existing shape exactly: a provider with no credentials is **omitted** +rather than half-configured, so an operator running Discord-only is never +forced to supply credentials they do not have. + +**3. `requireEmail: false` — when, and why it is not optional.** SuperTokens' +API layer returns `NO_EMAIL_GIVEN_BY_PROVIDER` and fails the login *before* the +mapping override ever runs, unless `requireEmail: false` is set for a provider +that yields no email. This bit Discord: pinning the scope to `identify` (to +match what passport asks for) removed the email, and without `requireEmail: +false` every Discord login would have failed at the API layer where our own +code never sees it. Google's default scopes return an email, so it does not +need the flag — but if you narrow any provider's scopes, re-check this. + +**4. Register the OAuth app** with the redirect +`https:///auth/callback/google`. Note this is *only* the SuperTokens +path — a provider added now has no passport equivalent and does not need the +widening from Phase 1. + +**5. Environment and deployment.** Add `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` +to `.env.example`, `docker-compose.yml` and `unraid-template.xml`, following the +existing entries. + +**6. The login button.** This is where Tier 1 collides with Phase 5: the login +screen drives passport, and **passport has no Google strategy**. So a new +provider is reachable only through the SuperTokens client flow, which does not +exist yet. In practice this means: + +> **Adding a new OAuth provider requires the Phase 5 client work first.** There +> is no shortcut where the new provider works in `dual` through the old buttons. + +**7. Tests.** Mirror the existing ones: `buildProviders` includes/omits it on +credentials, `PROVIDER_IDS` contains it, and — most importantly — an +end-to-end mapping test in `tests/supertokens.mapping.test.js` proving a login +resolves to `google:` and creates exactly one identity. + +**What you do NOT need to touch:** the mapping override, the auth chain, +`requireAuth`, `requireRole`, any route handler, or the database schema. The +mapping is provider-agnostic — it keys off `(thirdPartyId, thirdPartyUserId)` +whatever those are. + +## Tier 2 — Non-OAuth methods (email+password, magic links, passkeys) + +Materially harder, because they break the assumption `users.id` is built on. + +**The identity problem.** `users.id` is `provider:providerId`, where +`providerId` is an immutable, provider-assigned id. Email/password has no such +thing: + +- **Do not use the email address as `providerId`.** It is user-changeable and + sometimes recycled. `email:alice@example.com` bakes a mutable value into a + primary key referenced by three foreign keys — and if a user ever changes + their email, either their save is orphaned or you are rewriting a primary key + across the whole database. +- **Use SuperTokens' own user id instead**, giving `emailpassword:`. + It is opaque, immutable, and already unique. This inverts the mapping + direction for these users — for OAuth, SuperTokens' id is mapped onto ours; + here ours is derived from theirs — so `server/supertokens/mapping.js` needs a + branch, and it is the one place in the codebase where getting it wrong loses + saves. Test it the way the OAuth path is tested, ordering assertions and all. + +**Other things that change:** + +- **The nodemailer advisory becomes live.** It is currently accepted as + unreachable *precisely because* only ThirdParty and Session recipes are + initialised, so no SMTP delivery service is ever constructed. Adding + `emailpassword`, `emailverification`, `passwordless` or `webauthn` makes that + reachable and the assessment must be redone. See the Task 2 findings in the + v1.8 plan. +- **You now run authentication infrastructure**, not just an OAuth + redirect: password reset, email verification, rate limiting on login, and an + SMTP sender that must actually deliver. Every one of those is a support + burden that Discord and GitHub currently absorb for you. +- **Passkeys (`webauthn`)** avoid passwords and email delivery, and are the + nicest of the three from a security standpoint — but SuperTokens' recipe is + newer, and you still need an account-recovery story for a lost device, which + usually drags email back in anyway. + +**Recommendation:** if the goal is "more ways to sign in", stay in Tier 1. +Tier 2 is worth it only if the goal is specifically "sign in without a +third-party account", and it deserves its own design document rather than a +section in this one. + +## The username trap + +`deriveUsername()` in `server/supertokens/mapping.js` picks a display name from +the provider profile for **new players only** — a returning player's username +is never touched, deliberately, so that a weaker derivation can never silently +rename someone mid-rollout. + +When adding a provider, check what its raw profile actually contains. The +current chain is `login` (GitHub) → `username` (Discord) → `global_name` → +`name` → email local-part → `${thirdPartyId}-${thirdPartyUserId}`. Google, for +instance, returns `name` (a display name, frequently "Firstname Lastname" with +a space) and no `login` or `username`. That will collide with the +`USERNAME_RE` validation used elsewhere far more often than GitHub's `login` +does, and `upsertUser`'s collision suffixing (`-2`, `-3`) will fire a lot. +Decide deliberately what a Google player should be called on first login. + +And to be explicit, because it is the security-relevant half: **the username is +cosmetic and must never be part of identity.** Only `(provider, providerId)` +identifies a player. + +## Checklist for adding an OAuth provider + +- [ ] `thirdPartyId` chosen and understood to be permanent +- [ ] Linking question answered (Option A or B) — before shipping, not after +- [ ] `PROVIDER_IDS` + `buildProviders()` updated, omitting on missing credentials +- [ ] `requireEmail` re-checked if scopes were narrowed +- [ ] OAuth app registered with `/auth/callback/` +- [ ] `.env.example`, `docker-compose.yml`, `unraid-template.xml` updated +- [ ] Phase 5 client login flow exists (or the provider is unreachable) +- [ ] `deriveUsername()` checked against the provider's actual profile shape +- [ ] Mapping test proving a login resolves to `:` with one identity +- [ ] `npm run test:all` green on **both** backends +- [ ] `npm run shadow:check` still passes + +## Things that would break the identity model + +Collected because each one loses saves, silently: + +| Do not | Because | +|---|---| +| Rename an existing `provider` value | It is half a primary key and the prefix of every `users.id` for those players. Every save orphans. | +| Use an email, username, or any user-changeable value as `providerId` | It is baked into a primary key referenced by three foreign keys. | +| Auto-link accounts by matching email | Account takeover unless every provider verifies email ownership. Link only on an explicit action by a signed-in user. | +| Accept `oAuthTokens` at `signInUpPOST` | This is the bypass fixed in `rejectRawOAuthTokens`. A token from *any* OAuth app the victim authorised would authenticate as them. Keep the redirect-URI flow as the only path. | +| Create the user-id mapping after the session | The session carries SuperTokens' internal id forever and the player lands on an empty save, with no error. | +| Add a recipe without redoing the nodemailer assessment | The advisory is only unreachable because no email-bearing recipe is initialised. | diff --git a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md index 48beb44..7e4f0d3 100644 --- a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md +++ b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md @@ -454,6 +454,23 @@ exactly the ordering test. Neither mutation was caught by luck elsewhere. > SuperTokens access token expires. Cutting over to `dual` is unaffected; > cutting over to `supertokens` needs frontend refresh handling first. Recorded > here and carried into the runbook in Task 7. +> +> **Post-review correction (2026-08-06).** This understated the blocker. A +> second, larger client gap was found while writing +> `docs/authentication-methods.md`: `client/src/Login.jsx` hardcodes its +> buttons to `/auth/discord` and `/auth/github` — the *passport* routes, which +> `supertokens` mode deliberately does not register. So in that mode the login +> buttons fall through to the SPA and silently do nothing: **nobody can log +> in**, not merely "sessions expire awkwardly". Existing sessions keep working +> through the JWT fallback, which is exactly what makes it easy to miss. +> +> The server side is complete — SuperTokens' middleware serves +> `GET /auth/authorisationurl` and `POST /auth/signinup` and the mapping +> resolves correctly — so this is purely un-started frontend work, not a +> defect in the release. But it means `supertokens` mode is **unusable**, not +> "not recommended", and every document that said otherwise has been +> corrected. It also means enabling `dual` does not route live traffic through +> SuperTokens at all; it only makes those sessions acceptable. ### Task 5 diff --git a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md index aac7742..d1b63fa 100644 --- a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md +++ b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md @@ -261,12 +261,23 @@ Starts only after v1.7 is confirmed running in production. > Postgres, 39 e2e smoke assertions, and a real boot verified in each of the > three `AUTH_MODE` values. Version bumped to 1.8.0; not yet tagged. > -> One gap found in Task 4 and deliberately not closed in this release: the -> client does not use the SuperTokens frontend SDK, so it cannot refresh an -> expired access token. This does not affect `dual` (the legacy cookie still -> authenticates and the player stays logged in), but a `supertokens`-only -> cutover needs frontend refresh handling first. **`dual` is the intended -> resting state for v1.8.** +> **The client side of this design was never in scope and is not built.** Two +> gaps, both frontend, neither affecting `dual`: +> +> 1. **No SuperTokens login flow.** `client/src/Login.jsx` drives its buttons +> at the passport routes, which `supertokens` mode does not register — so in +> that mode the buttons silently do nothing and nobody can log in. The +> server side is complete (the middleware serves `/auth/authorisationurl` +> and `/auth/signinup`); the client has never been taught to call it. +> 2. **No session refresh.** No frontend SDK, so no interceptor to refresh an +> expired access token. Invisible in `dual` because the legacy cookie still +> authenticates. +> +> **`dual` is the intended resting state for v1.8**, and it is worth being +> precise about what `dual` proves: since the client still logs in through +> passport, enabling `dual` makes SuperTokens sessions *acceptable* without +> routing live traffic through them. See +> `docs/authentication-methods.md` Phase 5. > > Still true, and unchanged by any of the above: shadow mode has not been run > against production identities, and no cutover has happened anywhere. diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index 250cf4c..31a365f 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -58,8 +58,15 @@ worse than one that admits it has not: v1.8's rollout on v1.7 running in production, and that is still outstanding. - **No SuperTokens core has been run against this code outside tests.** Part B is written from the documented configuration, not from a stood-up instance. -- **`supertokens`-only mode is not recommended yet** — see D6. `dual` is the - intended resting state for this release. +- **`supertokens`-only mode cannot be used yet** — and the reason is bigger + than "not recommended". The client has never been taught to talk to + SuperTokens: `client/src/Login.jsx` points its buttons at the *passport* + routes, which `supertokens` mode does not register, so the login buttons + silently do nothing. Existing sessions keep working through the JWT + fallback, but nobody can log in. There is also no token refresh. Both are + frontend work that has not been started. See D6 and + [`authentication-methods.md`](./authentication-methods.md) Phase 5. + **`dual` is the intended resting state for this release.** None of that blocks *deploying* v1.8. All of it blocks *rolling it out*, and Part C exists to close the first item. @@ -427,19 +434,37 @@ There is no schedule to keep. `dual` is a stable state, not a transition — both stacks work, rollback stays free, and nothing degrades by leaving it there for weeks. -### D6. `supertokens` mode — not yet recommended +### D6. `supertokens` mode — do not use it yet -> **Read this before considering it.** The client does not use the SuperTokens -> frontend SDK, so it has no interceptor to refresh an expired access token. In -> `dual` that is harmless: when a SuperTokens session expires, the request -> falls through to the legacy JWT cookie and the player stays logged in. In -> `supertokens` mode, once a player's legacy cookie has also expired, there is -> nothing to fall through to and they would be silently logged out when the -> access token expires. +> **Two client-side gaps block this, and the first is not subtle.** > -> `supertokens` mode is implemented and tested, but **cutting over to it needs -> frontend refresh handling first**. `dual` is the intended resting state for -> this release. +> **1. Nobody can log in.** `client/src/Login.jsx` hardcodes its buttons to +> `/auth/discord` and `/auth/github` — the *passport* routes. `supertokens` +> mode does not register those, so the request falls through to the SPA and +> the button silently does nothing. Existing sessions keep working via the JWT +> fallback, so the app looks fine right up until someone tries to sign in. +> +> The server side is complete: SuperTokens' middleware serves +> `GET /auth/authorisationurl` and `POST /auth/signinup`, and the mapping +> resolves correctly. The client has simply never been taught to call them. +> +> **2. No session refresh.** There is no SuperTokens frontend SDK and so no +> interceptor to refresh an expired access token. In `dual` this is invisible +> because the legacy cookie still authenticates. In `supertokens` mode, once a +> player's legacy cookie has also expired, they are silently logged out when +> the access token expires. +> +> Both are frontend work that has **not been started**. `dual` is the intended +> resting state for this release. See +> [`authentication-methods.md`](./authentication-methods.md) Phase 5 for what +> building them involves. + +> **A nuance about what `dual` actually proves.** Because the client still +> drives every login through the passport routes, turning on `dual` does not +> by itself route anyone through SuperTokens — it makes SuperTokens sessions +> *acceptable* and stands the stack up so it can be exercised deliberately. Do +> not read a quiet `dual` deployment as evidence that the SuperTokens login +> path works end to end. ## Part E — Rollback From 50bea27627d2c0412841cf51f896b72e765c81b0 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Thu, 6 Aug 2026 23:54:34 -0400 Subject: [PATCH 13/14] Fix the final review's findings: the gate wrote, the core ran open Two Criticals and five Importants from the whole-branch review. CRITICAL - `npm run shadow:check` was not read-only. It imported server/db/index.js, whose top-level await builds a driver, which runs applySchema: on SQLite that is journal_mode=WAL, dedupeUsernames (an UPDATE renaming case-colliding accounts) and migrateIdentities (a users rebuild with foreign keys off). The reviewer pointed it at a pre-v1.7 export and watched a user get renamed before it printed GATE: PASS. The damage landed hardest in the DOCUMENTED PRIMARY USE - auditing a restored export - because a healthy v1.8 database mostly no-ops, and the runbook promised in bold that it issued nothing but SELECTs. The audit now opens its own connection (better-sqlite3 readonly + fileMustExist, or a pg READ ONLY transaction) and takes its reader by injection, so shadow.js has no import that could reach a migrating connection even by accident. The no-write test now runs the real entry point in a subprocess against a pre-v1.7 database and compares a full snapshot - the old one snapshotted from inside a process whose facade had already migrated, so the writes predated the look. CRITICAL - the compose file ran the SuperTokens core with no API_KEYS and published 3567. An open core serves POST /recipe/session, which mints a session for any userId; the id mapping turns that into a real RackStack session for any SUPER_ADMIN_IDS value, without a request ever reaching Express. Port unpublished, image pinned off :latest, and initSuperTokens now refuses to start a non-loopback core without a key. Enforced in server code rather than compose because compose interpolates the whole file before filtering by profile, so a required variable there would break `docker compose up` for deployments that never touch SuperTokens. Also fixed: - identities.supertokens_user_id was overwritten with our own users.id on every login after the first, since the core returns the external id once a mapping exists. Found independently by two reviewers. Now records existing.superTokensUserId, and the race path records instead of returning early. - The audit could not see identity rows orphaned from users - unreachable from the old users->identities walk, and exactly the corruption that leaves a player unable to log in. Enumerates identities directly with a LEFT JOIN and reports a distinct ORPHAN outcome that fails the gate. - A run of nothing but new players reported PASS: `passed` gated on total results, not comparable ones. The vacuous pass the module's own comment claims to prevent. - POST /auth/signout is registered automatically by the Session recipe and left the legacy cookie in place, so requireAuth re-authenticated the "logged out" user. Removed; /auth/logout already clears both stacks. - Two simultaneous first logins raced on Postgres: the loser's users_pkey 23505 was misread as a username collision, renamed, and retried into the same key. Now serialized per player with the existing userLock, and the pg driver distinguishes a PK conflict from a username conflict. Three mutations that survived the review now fail: the middleware/errorHandler mount (asserted by layer count and by HTTP content-type - the old `not.toContain('middleware')` check could never fail, since the bindings are anonymous), logout revoking the SuperTokens session, and resolveExternalUserId reading identity.user_id rather than reconstructing it. Minors: GENERAL_ERROR instead of a thrown Error at the bypass guard (a 500 reads as retryable), jwt.verify pinned to HS256, provider ids validated before being composed into users.id, exitCode instead of process.exit so a piped report cannot truncate, and a clear message when handed a pre-v1.7 export. 614 tests green on SQLite, 637 on Postgres. Co-Authored-By: Claude Opus 5 --- docker-compose.yml | 33 ++- server/auth.js | 6 +- server/db/driver.pg.js | 19 ++ server/supertokens/init.js | 100 +++++++- server/supertokens/mapping.js | 78 +++++- server/supertokens/shadow.js | 274 +++++++++++---------- server/supertokens/shadowCheck.js | 154 ++++++++++-- tests/supertokens.init.test.js | 66 ++++- tests/supertokens.mapping.test.js | 72 ++++++ tests/supertokens.middleware.test.js | 108 ++++++++ tests/supertokens.security.test.js | 17 +- tests/supertokens.shadow.test.js | 356 +++++++++++++++++++-------- 12 files changed, 993 insertions(+), 290 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c974703..1346f10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,15 +33,30 @@ services: # on a container it will not use is a good way to turn an unrelated # SuperTokens problem into a RackStack outage. supertokens: - image: registry.supertokens.io/supertokens/supertokens-postgresql:latest + # Pinned, not :latest. This container signs and validates every session in + # supertokens/dual mode; silently pulling a new major on the next `up -d` + # is not a risk worth taking for the component that IS the trust root. + image: registry.supertokens.io/supertokens/supertokens-postgresql:9.3 container_name: rackstack-supertokens profiles: ["supertokens"] restart: unless-stopped depends_on: postgres: condition: service_healthy - ports: - - "3567:3567" + # NOTE: the port is deliberately NOT published to the host. + # + # A SuperTokens core with no API key serves its entire API unauthenticated, + # and that API is the trust root of the whole stack: POST /recipe/session + # mints a session for ANY userId, and because the user-id mapping makes + # session.getUserId() return `github:37058311` verbatim, anyone who can + # reach this port can mint a valid RackStack session for any value in + # SUPER_ADMIN_IDS - which are deterministic and effectively public. That + # bypasses every guard in the Express app without touching it. + # + # rackstack reaches the core over the compose network as `supertokens:3567` + # and does not need a published port. To poke it by hand, use + # docker compose exec supertokens bash -c ':> /dev/tcp/127.0.0.1/3567' + # rather than re-adding a `ports:` mapping. environment: # Its OWN database, never the rackstack one - SuperTokens manages its # own schema and must not share a database with application tables. @@ -51,6 +66,18 @@ services: # own DATABASE_URL above accepts either - v1.7 verified that directly, # against an earlier claim to the contrary.) POSTGRESQL_CONNECTION_URI: postgresql://rackstack:rackstack@postgres:5432/supertokens + # Defence in depth behind the unpublished port: even on the compose + # network, the core should not answer to anything that merely reaches it. + # Must match SUPERTOKENS_API_KEY on the rackstack service. Generate with + # `openssl rand -hex 32` and put it in .env. + # + # Deliberately NOT the ${VAR:?error} form. Compose interpolates the whole + # file before it filters by profile, so a required-variable error here + # would break plain `docker compose up` for every deployment that never + # touches SuperTokens. The requirement is enforced in server code + # instead (see initSuperTokens), where it can fire only for operators who + # have actually opted in. + API_KEYS: ${SUPERTOKENS_API_KEY:-} healthcheck: test: ["CMD-SHELL", "bash -c ':> /dev/tcp/127.0.0.1/3567' || exit 1"] interval: 10s diff --git a/server/auth.js b/server/auth.js index 4f19201..f2bfdca 100644 --- a/server/auth.js +++ b/server/auth.js @@ -193,7 +193,11 @@ export async function requireAuth(req, res, next) { const token = req.cookies && req.cookies[COOKIE_NAME]; if (!token) return res.status(401).json({ error: 'not authenticated' }); try { - req.user = jwt.verify(token, JWT_SECRET); + // Algorithm pinned explicitly. jsonwebtoken v9 already restricts a string + // secret to HMAC and rejects `alg: none`, so this closes no live hole - + // it just stops the guarantee depending on a library default, which is + // the sort of thing that changes in a major version nobody re-audits. + req.user = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }); } catch (e) { return res.status(401).json({ error: 'invalid or expired token' }); } diff --git a/server/db/driver.pg.js b/server/db/driver.pg.js index 63fb251..24c709e 100644 --- a/server/db/driver.pg.js +++ b/server/db/driver.pg.js @@ -153,6 +153,25 @@ export async function createPgDriver({ url }) { // block that account from ever logging in. Pick a free variant using // the same suffixing convention as dedupeUsernames and retry once. if (e.code !== '23505') throw e; // unique_violation + + // Not every 23505 here is a username collision, and treating them all + // as one is how a race turned into a failed login. Two simultaneous + // FIRST logins for the same player both see no identity and both + // insert; the loser violates `users_pkey`, not the username index. + // Renaming and retrying then re-inserts the SAME primary key, fails + // again, and this time propagates - so the player's very first login + // errors out. (SQLite avoids this by accident: nothing awaits between + // its identity read and its insert. Dialect drift, found by the v1.8 + // final review against a real Postgres container.) + // + // The winner's row is correct and complete, so the right recovery is + // simply to adopt it. + if (e.constraint === 'users_pkey') { + const winner = await one('SELECT * FROM users WHERE id = $1', [id]); + if (winner) return winner; + throw e; + } + user.username = await findAvailableUsername(username, isUsernameTakenInDb); await insertUserAndIdentity(user, identityRow); } diff --git a/server/supertokens/init.js b/server/supertokens/init.js index 5925dba..70f6454 100644 --- a/server/supertokens/init.js +++ b/server/supertokens/init.js @@ -77,18 +77,74 @@ export function rejectRawOAuthTokens(originalImplementation) { ? undefined : async function signInUpPOST(input) { if (input.redirectURIInfo === undefined) { - throw new Error( - 'signInUp requires the redirect-URI flow. Submitting oAuthTokens directly is ' - + 'not accepted: RackStack cannot verify that such a token was issued to this ' - + 'application, so honouring it would let any third-party token authenticate as ' - + 'its owner.', - ); + // GENERAL_ERROR rather than a thrown Error. Throwing here reached + // Express's default handler as a 500, which is both less precise and + // actively misleading: a 500 reads as transient, so a client would + // retry a request that can never succeed. (No internals leaked - the + // shipped image sets NODE_ENV=production - but a bare-metal + // `npm start` without it would have returned the stack.) + // GENERAL_ERROR is the SDK's own contract for "refused, do not + // retry", and it carries the reason to the caller. + return { + status: 'GENERAL_ERROR', + message: + 'signInUp requires the redirect-URI flow. Submitting oAuthTokens directly is ' + + 'not accepted: RackStack cannot verify that such a token was issued to this ' + + 'application, so honouring it would let any third-party token authenticate as ' + + 'its owner.', + }; } return originalImplementation.signInUpPOST(input); }, }; } +/** + * Removes the Session recipe's stock `POST /auth/signout`. + * + * The Session recipe registers that endpoint automatically, and it revokes the + * SuperTokens session while leaving RackStack's legacy JWT cookie untouched. + * `requireAuth`'s fallback branch then re-authenticates the supposedly + * logged-out user on the very next request: the UI says signed out, the server + * disagrees, and on a shared machine that is account exposure rather than a + * cosmetic bug. + * + * It is exactly the half-logout `server/routes/authRoutes.js` was written to + * prevent - the guarantee was simply enforced on `/auth/logout`, the route the + * current client happens to call, while the SDK quietly published a second + * door. The SuperTokens frontend SDK's `signOut()` targets `/auth/signout`, so + * this would have become the DEFAULT path the moment the planned Phase 5 + * frontend work landed. + * + * Removed rather than patched: `/auth/logout` already clears both stacks, and + * one logout route that is known to be complete beats two that must be kept in + * agreement forever. A client calling `/auth/signout` gets a 404, which is + * loud - and a logout that fails loudly is strictly better than one that half + * works. + */ +/** + * Whether a connection URI points at this host only. + * + * Parsed rather than string-matched, so `http://127.0.0.1.evil.com:3567` is + * correctly treated as remote - a substring check for '127.0.0.1' would wave + * it through, which is the classic way this kind of exemption goes wrong. + */ +export function isLoopback(uri) { + try { + const { hostname } = new URL(uri); + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' + || hostname === '[::1]'; + } catch { + // Unparseable means we cannot establish it is local, so treat it as remote + // and require the key. Failing towards "more authentication" is correct. + return false; + } +} + +export function disableStockSignOut(originalImplementation) { + return { ...originalImplementation, signOutPOST: undefined }; +} + /** * Initialises SuperTokens if the mode calls for it. * @@ -128,6 +184,36 @@ export async function initSuperTokens({ env = process.env, mode } = {}) { ); } + // Last gate before anything is loaded, and deliberately after the plain + // configuration errors above - an operator with three things wrong should + // hear about the missing origin before the security posture. + // + // A SuperTokens core with no API key serves its whole API unauthenticated, + // and that API is the trust root: POST /recipe/session mints a session for + // ANY userId, and the user-id mapping makes session.getUserId() return + // `github:37058311` verbatim - so anyone who can reach the core can mint a + // session for any SUPER_ADMIN_IDS value, which are deterministic and + // effectively public. No request to the Express app is involved, so none of + // the guards in this codebase apply to it. + // + // Enforced here rather than in docker-compose.yml because Compose + // interpolates the whole file before filtering by profile: a required + // variable there would break `docker compose up` for every deployment that + // never enables SuperTokens. Here it fires only for operators who opted in. + // + // Loopback is exempt: such a core is reachable only from this host, which is + // the normal shape for a local development run, and demanding a key there + // would only teach people to set a dummy one. + if (!env.SUPERTOKENS_API_KEY && !isLoopback(connectionURI)) { + throw new Error( + `AUTH_MODE='${mode}' requires SUPERTOKENS_API_KEY when the SuperTokens core is not on ` + + `loopback (got ${connectionURI}). An unauthenticated core lets anyone who can reach ` + + 'it mint a session for any user id, including every value in SUPER_ADMIN_IDS. ' + + 'Generate one with `openssl rand -hex 32`, set it as API_KEYS on the core and ' + + "SUPERTOKENS_API_KEY here, and do not publish the core's port to the network.", + ); + } + const [supertokens, Session, ThirdParty] = await Promise.all([ import('supertokens-node').then((m) => m.default ?? m), import('supertokens-node/recipe/session').then((m) => m.default ?? m), @@ -160,7 +246,7 @@ export async function initSuperTokens({ env = process.env, mode } = {}) { apis: rejectRawOAuthTokens, }, }), - Session.init(), + Session.init({ override: { apis: disableStockSignOut } }), ], }); diff --git a/server/supertokens/mapping.js b/server/supertokens/mapping.js index d5068d1..1e66de5 100644 --- a/server/supertokens/mapping.js +++ b/server/supertokens/mapping.js @@ -38,6 +38,7 @@ import { upsertUser as dbUpsertUser, setSupertokensUserId as dbSetSupertokensUserId, } from '../db/index.js'; +import { withUserLock } from '../userLock.js'; /** * The default database dependency set. Injected rather than imported directly @@ -110,6 +111,22 @@ export async function resolveExternalUserId(input, db = defaultDb) { ); } + // `users.id` is `${provider}:${providerId}`, so the provider id is about to + // become half of a composite primary key that three foreign keys point at. + // Both current providers return opaque numeric ids, and cross-provider + // collision is impossible anyway because the prefixes are disjoint - so this + // is not reachable today. It is asserted rather than inherited because the + // invariant is load-bearing and the next provider added might not be numeric: + // a provider id containing a colon would make the composition ambiguous, and + // ambiguity in an account identifier is not a thing to discover later. + if (!/^[A-Za-z0-9._~-]+$/.test(thirdPartyUserId)) { + throw new Error( + 'SuperTokens supplied a provider user id with unexpected characters ' + + `(${thirdPartyId}:${thirdPartyUserId}). users.id is composed from it, so refusing ` + + 'rather than minting an ambiguous account identifier.', + ); + } + const identity = await db.getIdentity(thirdPartyId, thirdPartyUserId); if (identity) return { externalUserId: identity.user_id, created: false }; @@ -148,6 +165,24 @@ export async function linkExternalUserId( userId: supertokensUserId, userIdType: 'ANY', }); + // The value to record on our side. It is NOT always the `supertokensUserId` + // argument: on a returning login the core has already translated, so what + // arrives here is the EXTERNAL id. Writing that back would overwrite the real + // SuperTokens id with our own `users.id` on every login after the first - + // which is what this code did until the v1.8 final review caught it. + // + // The consequences were all latent but real: the column stopped recording the + // linkage exactly when a rollout would need to reverse-map an ST id to a + // player; a crash between the two writes self-healed into the wrong value + // rather than the right one; and once account linking ships, two identities + // on one `users.id` would both write that same `users.id` and collide on the + // UNIQUE constraint - turning a benign case into a permanent login failure, + // and hollowing out the constraint's documented meaning ("two identities were + // handed the same SuperTokens id - genuine corruption"). + // + // The OK branch has the authoritative value to hand: the core just told us. + let recordedSupertokensUserId = supertokensUserId; + if (existing.status === 'OK') { if (existing.externalUserId !== externalUserId) { throw new Error( @@ -156,6 +191,7 @@ export async function linkExternalUserId( + `'${externalUserId}'. Refusing to issue a session rather than serve the wrong save.`, ); } + recordedSupertokensUserId = existing.superTokensUserId; } else { const result = await supertokens.createUserIdMapping({ superTokensUserId: supertokensUserId, @@ -174,6 +210,12 @@ export async function linkExternalUserId( userId: supertokensUserId, userIdType: 'ANY', }); if (raced.status === 'OK' && raced.externalUserId === externalUserId) { + // Fall through to the bookkeeping write rather than returning early. + // The race loser used to skip it, which was the one successful path + // that recorded nothing - and, after the fix above, the one path that + // would have left the column empty rather than merely wrong. + recordedSupertokensUserId = raced.superTokensUserId; + await db.setSupertokensUserId(thirdPartyId, thirdPartyUserId, recordedSupertokensUserId); return externalUserId; } } @@ -187,7 +229,7 @@ export async function linkExternalUserId( // Our-side bookkeeping, deliberately last: the core-side mapping is what // governs the session, and this column only records that it happened. - await db.setSupertokensUserId(thirdPartyId, thirdPartyUserId, supertokensUserId); + await db.setSupertokensUserId(thirdPartyId, thirdPartyUserId, recordedSupertokensUserId); return externalUserId; } @@ -223,14 +265,32 @@ export function buildSignInUpOverride({ db = defaultDb, supertokens } = {}) { if (response.status !== 'OK') return response; const supertokensUserId = readSupertokensUserId(response); - const { externalUserId } = await resolveExternalUserId(input, db); - - await linkExternalUserId({ - supertokensUserId, - externalUserId, - thirdPartyId: input.thirdPartyId, - thirdPartyUserId: input.thirdPartyUserId, - }, { db, supertokens }); + + // Serialized per player for the same reason every save write is: there + // are three awaited round trips between "does this identity exist?" and + // the insert that creates it, and two simultaneous FIRST logins for one + // player both saw "no" and both inserted. On Postgres the loser hit + // SQLSTATE 23505 on users_pkey, which upsertUser's catch misread as a + // USERNAME collision, renamed and retried the same primary key, and + // failed again uncaught - a failed login, verified against a real + // container by the v1.8 final review. SQLite happened to be safe, since + // nothing awaits between its read and insert: dialect drift, which is + // precisely what the two-backend rule exists to surface. + // + // The lock key is available before the user exists - it is literally the + // users.id about to be created. Not reentrant, but nothing on this path + // takes the same lock. + const lockKey = `${input.thirdPartyId}:${input.thirdPartyUserId}`; + await withUserLock(lockKey, async () => { + const { externalUserId } = await resolveExternalUserId(input, db); + + await linkExternalUserId({ + supertokensUserId, + externalUserId, + thirdPartyId: input.thirdPartyId, + thirdPartyUserId: input.thirdPartyUserId, + }, { db, supertokens }); + }); return response; }, diff --git a/server/supertokens/shadow.js b/server/supertokens/shadow.js index 49a0031..95c3048 100644 --- a/server/supertokens/shadow.js +++ b/server/supertokens/shadow.js @@ -1,65 +1,52 @@ // Shadow mode: the gate that has to read 100% before anyone cuts over. // // The whole release rests on one equality - that SuperTokens' `thirdPartyUserId` -// is the same string passport stored as `provider_id`. Task 3's implementation -// notes record that this has now been verified at the SOURCE level for both -// providers at their pinned versions, which is a real improvement on "assumed". -// It is still not sufficient, and this module exists because of the gap: +// is the same string passport stored as `provider_id`. That has two halves, and +// only one can be checked by reading code: // -// Reading the libraries tells you what they will write TOMORROW. -// The rows in `identities` were written by whatever versions were installed -// on the day each player first logged in, going back to v1.0. +// 1. What SuperTokens WILL compute. Verified at the source level for both +// providers at their pinned versions (design section 5.3). +// 2. What is ALREADY STORED in `identities`. Those rows were written by +// whatever library versions were installed the day each player first +// logged in, going back to v1.0. No amount of reading today's libraries +// settles it. // -// So the only evidence that actually settles it is the owner's production -// `identities` table. This module compares a real SuperTokens login against -// those real rows and reports what it finds. +// This module reads half 2. // -// The failure this prevents is specific and unrecoverable: a mismatched id -// means a returning player is treated as brand new, silently lands on an empty -// save, and - if they play on it before anyone notices - cannot be given their -// old one back without a restore. There is no error and no log line at the -// moment it happens. Hence a gate, run before the switch, rather than -// monitoring afterwards. +// The failure it prevents is specific and unrecoverable: a mismatched id means +// a returning player is treated as brand new, silently lands on an empty save, +// and - if they play on it before anyone notices - cannot be given their old +// one back without a restore. There is no error and no log line at the moment +// it happens. Hence a gate, run before the switch, rather than monitoring +// afterwards. // -// SAFETY: nothing in this file writes. It is designed to be run against -// production while players are logged in and playing, so it does not touch the -// caller's session, does not create users, and issues no statement other than -// the SELECT inside `getIdentity`. tests/supertokens.shadow.test.js asserts -// that by snapshotting the whole identities table around a run. +// SAFETY: nothing here writes, and - since the v1.8 final review - nothing here +// can write. The audit no longer goes through `server/db/index.js`, because +// importing that facade builds a driver, and building a driver runs +// `applySchema`, which on SQLite renames colliding usernames and rebuilds the +// `users` table. The tool advertised as safe to point at production was +// migrating it. See the read-only reader contract below and +// `server/supertokens/shadowCheck.js`, which supplies it. -import { - getIdentity as dbGetIdentity, - getAllUsersWithSaves as dbGetAllUsersWithSaves, - listIdentities as dbListIdentities, -} from '../db/index.js'; - -const defaultDb = { - getIdentity: dbGetIdentity, - getAllUsersWithSaves: dbGetAllUsersWithSaves, - listIdentities: dbListIdentities, -}; - -/** The three things a comparison can conclude. */ +/** The outcomes a comparison can reach. */ export const SHADOW_MATCH = 'match'; export const SHADOW_MISMATCH = 'mismatch'; export const SHADOW_NO_IDENTITY = 'no-identity'; +export const SHADOW_ORPHAN = 'orphan'; /** - * Compares one completed SuperTokens third-party login against `identities`. + * Compares one completed SuperTokens third-party login against a stored + * identity, for the LIVE per-login check (createShadowRun, runbook part C4). * - * Returns a plain result object; it never throws for a mismatch, because a - * mismatch is a finding to be reported rather than an error to be handled. + * Returns a plain result object; a mismatch is a finding to be reported, not an + * error to be thrown. * - * `no-identity` is NOT a failure. It is what a genuinely new player looks - * like, and also what a player who has simply never logged in through this - * provider looks like. Conflating it with `mismatch` would make the gate - * unreadable on any server that has ever had a new signup - which is why the - * summary below counts the three outcomes separately. + * `no-identity` is NOT a failure. It is what a genuinely new player looks like. + * Conflating it with `mismatch` would make the gate unreadable on any server + * that has ever had a signup - which is why every outcome is counted + * separately, and why `summarise` excludes it from the rate entirely. */ -export async function compareIdentity( - { thirdPartyId, thirdPartyUserId }, - db = defaultDb, -) { +export async function compareIdentity({ thirdPartyId, thirdPartyUserId }, db) { const expectedUserId = `${thirdPartyId}:${thirdPartyUserId}`; const identity = await db.getIdentity(thirdPartyId, thirdPartyUserId); @@ -82,7 +69,51 @@ export async function compareIdentity( }; } -/** One line per login, so a tail of the logs during shadow mode is readable. */ +/** Classifies one stored identity row. Pure; shared by both entry points. */ +export function classifyIdentityRow({ provider, provider_id: providerId, user_id: userId, user_exists: userExists }) { + const expectedUserId = `${provider}:${providerId}`; + let outcome; + if (!userExists) { + // A login method pointing at an account that does not exist. The player can + // never log in: resolveExternalUserId hands back a users.id with no row, + // and requireAuth then refuses the session. Precisely the corruption class + // this gate exists to surface, and it was invisible to the audit until the + // final review - the old enumeration walked users -> identities, so a row + // unreachable from `users` could not be visited at all. + outcome = SHADOW_ORPHAN; + } else if (userId === expectedUserId) { + outcome = SHADOW_MATCH; + } else { + outcome = SHADOW_MISMATCH; + } + return { + outcome, thirdPartyId: provider, thirdPartyUserId: providerId, expectedUserId, actualUserId: userId, + }; +} + +/** + * Audits every identity row already stored, without any login happening. + * + * This is the form of the gate that can actually be run BEFORE cutover, and the + * one the runbook tells the operator to use. The live per-login form below + * needs the SuperTokens stack reachable and someone to log in through it - + * which is most of what the gate is meant to clear, i.e. a gate you can only + * open after walking through the door. + * + * `readAllIdentities` is injected rather than imported, and that is a safety + * boundary rather than a testing convenience: this module must not be able to + * reach a connection that runs migrations. The caller supplies a read-only + * reader returning rows of + * `{ provider, provider_id, user_id, user_exists }` - see shadowCheck.js. + */ +export async function auditStoredIdentities({ readAllIdentities, log = () => {} }) { + const rows = await readAllIdentities(); + const results = rows.map(classifyIdentityRow); + for (const result of results) log(formatResult(result)); + return results; +} + +/** One line per row/login, so tailing the log during a run is readable. */ export function formatResult(result) { const who = `${result.thirdPartyId}:${result.thirdPartyUserId}`; switch (result.outcome) { @@ -91,61 +122,74 @@ export function formatResult(result) { case SHADOW_MISMATCH: return `[shadow] MISMATCH ${who} - SuperTokens implies '${result.expectedUserId}' ` + `but identities has '${result.actualUserId}'. This player would land on the WRONG save.`; + case SHADOW_ORPHAN: + return `[shadow] ORPHAN ${who} - points at user '${result.actualUserId}', which does not exist. ` + + 'This player cannot log in at all.'; default: return `[shadow] NO-IDENTITY ${who} - no such row; this is a new player, not a failure.`; } } /** - * Rolls a set of results into the number an operator makes the cutover - * decision on. + * Rolls results into the number the cutover decision is made on. + * + * `passed` requires at least one COMPARABLE result and no failures. "Comparable" + * excludes `no-identity`, and that distinction is the whole point: gating on the + * total instead let a run of nothing but new players report + * "100% of comparable logins matched" having compared none of them - the exact + * vacuous pass this function's own contract exists to prevent, found by the + * v1.8 final review. * - * `passed` is true only when there is at least one comparison AND no - * mismatches. The "at least one" clause is the important half: an empty run - * has a 100% match rate by vacuous arithmetic, and a gate that reports PASS - * because it compared nothing is worse than no gate at all - it manufactures - * exactly the false confidence the gate exists to prevent. A run that compared - * nothing has not been run. + * Orphans count as failures, not curiosities. A player who cannot log in is not + * a passing state. */ export function summarise(results) { const matched = results.filter((r) => r.outcome === SHADOW_MATCH); const mismatched = results.filter((r) => r.outcome === SHADOW_MISMATCH); + const orphaned = results.filter((r) => r.outcome === SHADOW_ORPHAN); const missing = results.filter((r) => r.outcome === SHADOW_NO_IDENTITY); + const comparable = matched.length + mismatched.length + orphaned.length; + return { total: results.length, matched: matched.length, mismatched: mismatched.length, + orphaned: orphaned.length, noIdentity: missing.length, - // Percentage of comparisons that had something to compare against. - matchRate: matched.length + mismatched.length === 0 - ? null - : matched.length / (matched.length + mismatched.length), - mismatches: mismatched.map((r) => ({ - thirdPartyId: r.thirdPartyId, - thirdPartyUserId: r.thirdPartyUserId, - expectedUserId: r.expectedUserId, - actualUserId: r.actualUserId, - })), - passed: results.length > 0 && mismatched.length === 0, + comparable, + matchRate: comparable === 0 ? null : matched.length / comparable, + mismatches: mismatched.map(pickPair), + orphans: orphaned.map(pickPair), + passed: comparable > 0 && mismatched.length === 0 && orphaned.length === 0, + }; +} + +function pickPair(r) { + return { + thirdPartyId: r.thirdPartyId, + thirdPartyUserId: r.thirdPartyUserId, + expectedUserId: r.expectedUserId, + actualUserId: r.actualUserId, }; } /** - * The summary an operator reads before deciding to cut over. + * The report an operator reads before deciding to cut over. * - * Deliberately blunt. Anything other than a clean pass says so on its own - * line, in words, with every mismatching pair named - a gate whose failure - * has to be inferred from a percentage is a gate people talk themselves past - * at the end of a long maintenance window. + * Deliberately blunt. Anything other than a clean pass says so on its own line, + * in words, with every offending pair named - a gate whose failure has to be + * inferred from a percentage is a gate people talk themselves past at the end + * of a long maintenance window. */ export function formatSummary(summary) { const lines = [ '=== SuperTokens shadow-mode report ===', - `logins compared: ${summary.total}`, + `identities compared: ${summary.comparable}`, `matched: ${summary.matched}`, `mismatched: ${summary.mismatched}`, - `no existing identity: ${summary.noIdentity} (new players - not failures)`, + `orphaned: ${summary.orphaned} (identity points at a missing user)`, + `no existing identity: ${summary.noIdentity} (new players - not failures, not compared)`, ]; if (summary.matchRate !== null) { @@ -162,18 +206,26 @@ export function formatSummary(summary) { } } + if (summary.orphans.length > 0) { + lines.push('', 'ORPHANS - these players cannot log in at all:'); + for (const o of summary.orphans) { + lines.push(` ${o.thirdPartyId}:${o.thirdPartyUserId} - points at missing user '${o.actualUserId}'`); + } + } + lines.push(''); if (summary.passed) { - lines.push('GATE: PASS - 100% of comparable logins matched. Cutover to AUTH_MODE=dual is cleared.'); - } else if (summary.total === 0) { + lines.push('GATE: PASS - 100% of comparable identities matched. Cutover to AUTH_MODE=dual is cleared.'); + } else if (summary.comparable === 0) { lines.push( - 'GATE: NOT RUN - nothing was compared. This is not a pass. Run at least one ' - + 'real login through shadow mode before cutting over.', + 'GATE: NOT RUN - nothing comparable was found. This is not a pass. Check that ' + + 'DATABASE_URL / DB_PATH point at the database you meant to audit.', ); } else { + const failures = summary.mismatched + summary.orphaned; lines.push( - `GATE: FAIL - ${summary.mismatched} mismatch(es). Do NOT cut over. ` - + 'Every mismatch is a player who would silently land on an empty save.', + `GATE: FAIL - ${failures} problem(s). Do NOT cut over. Every one is a player who ` + + 'would land on the wrong save or be unable to log in.', ); } @@ -181,66 +233,16 @@ export function formatSummary(summary) { } /** - * Audits every identity row already in the database, without any login - * happening at all. - * - * This is the form of the gate that can actually be run BEFORE cutover, and it - * is the one the runbook tells the operator to use first. The live per-login - * form below needs the SuperTokens stack to be reachable and someone to log in - * through it — which is most of the thing the gate is supposed to clear — so on - * its own it would be a gate you can only open after walking through the door. - * - * It works because the residual risk is entirely on one side. The equality this - * release rests on has two halves: + * Collects results for the LIVE per-login check (runbook part C4). * - * 1. What SuperTokens will compute for `thirdPartyUserId`. Verified at the - * source level for both providers at their pinned versions (design §5.3). - * 2. What is actually stored in `identities.provider_id`, written by whatever - * library versions were installed on the day each player first logged in. - * - * Only (2) is unverifiable by reading code, and (2) is exactly what this reads. - * For every row it asks the one question that matters: does `user_id` equal - * `provider:provider_id`? If that holds for 100% of rows, then any login whose - * `thirdPartyUserId` matches `provider_id` resolves to the right save. - * - * Enumerates through `getAllUsersWithSaves` + `listIdentities` rather than a - * new "list every identity" interface function, so it needs no schema or - * interface change and runs against a plain restored export. - * - * Read-only, like everything else here. - */ -export async function auditStoredIdentities({ db = defaultDb, log = () => {} } = {}) { - const users = await db.getAllUsersWithSaves(); - const results = []; - - for (const user of users) { - // eslint-disable-next-line no-await-in-loop - const identities = await db.listIdentities(user.id); - for (const identity of identities) { - const expectedUserId = `${identity.provider}:${identity.provider_id}`; - const result = { - outcome: identity.user_id === expectedUserId ? SHADOW_MATCH : SHADOW_MISMATCH, - thirdPartyId: identity.provider, - thirdPartyUserId: identity.provider_id, - expectedUserId, - actualUserId: identity.user_id, - }; - results.push(result); - log(formatResult(result)); - } - } - - return results; -} - -/** - * Collects shadow results across a run. + * Optional belt-and-braces once `dual` is on. It cannot be the gate - it needs + * the SuperTokens stack reachable and someone logging in through it. The + * offline audit above is the gate. * - * Kept as an explicit collector rather than module-level state so two runs - * cannot contaminate each other, and so a caller can hold one per operator - * session. + * An explicit collector rather than module state, so two runs cannot + * contaminate each other. */ -export function createShadowRun({ db = defaultDb, log = console.log } = {}) { +export function createShadowRun({ db, log = console.log } = {}) { const results = []; return { diff --git a/server/supertokens/shadowCheck.js b/server/supertokens/shadowCheck.js index 3248109..16b7f58 100644 --- a/server/supertokens/shadowCheck.js +++ b/server/supertokens/shadowCheck.js @@ -2,34 +2,144 @@ // Operator entry point for the shadow-mode gate: `npm run shadow:check`. // // Audits every identity row in whichever database the usual environment -// variables point at (DATABASE_URL for Postgres, DB_PATH for SQLite - the same -// resolution the server itself uses, so there is no second place to get it -// wrong) and prints the report the cutover decision is made from. +// variables point at (DATABASE_URL for Postgres, DB_PATH for SQLite) and prints +// the report the cutover decision is made from. // -// Read-only. Safe to run against production while players are online, and safe -// to run against a restored export on a laptop - which is the intended use, -// since the gate has to clear BEFORE the SuperTokens stack is switched on. +// READ-ONLY, AND STRUCTURALLY SO. This file deliberately does NOT import +// `server/db/index.js`, and must never be changed to. That facade resolves a +// driver at module-evaluation time, and both drivers run `applySchema` before +// returning - which on SQLite sets `journal_mode = WAL`, runs `dedupeUsernames` +// (an `UPDATE users SET username`, renaming case-colliding accounts) and +// `migrateIdentities` (a full `users` table rebuild with foreign keys off). // -// Exit code is the machine-readable form of the gate: 0 only on a clean pass. -// A non-zero exit on "nothing was compared" is deliberate - an empty run is not -// a pass, and a script that exited 0 on it would quietly bless a cutover -// against a database it never actually read. +// So the previous version of this script, whose banner promised it issued +// "nothing but SELECTs", silently migrated and rewrote any database it was +// pointed at - and the damage landed hardest in the documented primary use, +// auditing a restored pre-v1.7 export, because a healthy v1.8 database mostly +// no-ops. Found by the v1.8 final review, which ran it against an export and +// watched a user get renamed before it printed GATE: PASS. +// +// Hence: our own connection, opened read-only, one SELECT, no schema code on +// the path at all. The same reason the audit takes its reader by injection - +// `shadow.js` cannot reach a migrating connection even by accident. +// +// Exit code is the machine-readable gate: 0 only on a clean pass. Non-zero on +// "nothing comparable was found" is deliberate - an empty run is not a pass, +// and a script that exited 0 on it would quietly bless a cutover against a +// database it never read. import { auditStoredIdentities, summarise, formatSummary } from './shadow.js'; -import { driver } from '../db/index.js'; +import { resolveSqlitePath } from '../db/shared.js'; -async function main() { - const results = await auditStoredIdentities({ log: (line) => console.log(line) }); - const summary = summarise(results); +// One statement, valid on both dialects. The LEFT JOIN is what surfaces an +// identity row whose user_id points at nothing: enumerating users and asking +// for each one's identities - the pre-review approach - could not see such a +// row at all, because it is unreachable from `users`. +const AUDIT_SQL = ` + SELECT i.provider, i.provider_id, i.user_id, + CASE WHEN u.id IS NULL THEN 0 ELSE 1 END AS user_exists + FROM identities i + LEFT JOIN users u ON u.id = i.user_id + ORDER BY i.provider, i.provider_id +`; + +/** + * Opens a read-only Postgres reader. + * + * The connection is put in an explicitly READ ONLY transaction rather than + * merely being trusted to issue a SELECT: it makes the guarantee the database + * enforces rather than something a future edit could quietly break. + */ +// A database with no `identities` table predates the v1.7 split, so there is +// nothing for this gate to audit. Worth its own message: the runbook sends +// operators here with "a restored export", and restoring the WRONG (pre-v1.7) +// export is an easy mistake whose natural symptom would otherwise be a raw +// "no such table" from deep inside a SELECT. +const NO_IDENTITIES_TABLE = new Error( + 'This database has no `identities` table, so it predates the v1.7 auth split and there ' + + 'is nothing to audit. Point DB_PATH / DATABASE_URL at a v1.7-or-later database - if this ' + + 'is a production export, migrate it to v1.7 first (see docs/postgres-migration-runbook.md).', +); + +async function pgReader(url) { + const pg = (await import('pg')).default; + // Registers the BIGINT->Number parser, so counts and timestamps read back as + // numbers exactly as they do through the normal driver. + await import('../db/pgTypes.js'); + const client = new pg.Client({ connectionString: url }); + await client.connect(); + return { + async readAllIdentities() { + await client.query('BEGIN TRANSACTION READ ONLY'); + try { + const present = await client.query( + "SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'identities'", + ); + if (present.rowCount === 0) throw NO_IDENTITIES_TABLE; + return (await client.query(AUDIT_SQL)).rows; + } finally { + await client.query('COMMIT'); + } + }, + async close() { await client.end(); }, + }; +} - console.log(''); - console.log(formatSummary(summary)); +/** + * Opens a read-only SQLite reader. + * + * `readonly: true` means better-sqlite3 will refuse a write at the driver + * level, and - just as importantly - opening this way does not create the file, + * so a typo'd DB_PATH reports a missing database instead of silently auditing a + * brand-new empty one and reporting NOT RUN. + */ +async function sqliteReader(path) { + const Database = (await import('better-sqlite3')).default; + const db = new Database(path, { readonly: true, fileMustExist: true }); + return { + async readAllIdentities() { + const present = db.prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'identities'", + ).get(); + if (!present) throw NO_IDENTITIES_TABLE; + return db.prepare(AUDIT_SQL).all(); + }, + async close() { db.close(); }, + }; +} + +export async function openReader(env = process.env) { + return env.DATABASE_URL + ? pgReader(env.DATABASE_URL) + : sqliteReader(resolveSqlitePath(env)); +} - if (driver.__backend === 'pg') await driver.__raw.end(); - process.exit(summary.passed ? 0 : 1); +async function main() { + const reader = await openReader(); + let summary; + try { + const results = await auditStoredIdentities({ + readAllIdentities: reader.readAllIdentities, + log: (line) => console.log(line), + }); + summary = summarise(results); + console.log(''); + console.log(formatSummary(summary)); + } finally { + await reader.close(); + } + + // exitCode rather than process.exit(): Node's stdout is asynchronous when + // piped, so exiting immediately after console.log can truncate the report - + // and the tail is where the mismatch and orphan lists are, i.e. exactly what + // an operator runs `| tee gate.log` to keep. + process.exitCode = summary.passed ? 0 : 1; } -main().catch((e) => { - console.error('[shadow] the audit failed to run:', e); - process.exit(2); -}); +// Only run when invoked as a script, so tests can import openReader. +if (process.argv[1] && process.argv[1].endsWith('shadowCheck.js')) { + main().catch((e) => { + console.error('[shadow] the audit failed to run:', e); + process.exitCode = 2; + }); +} diff --git a/tests/supertokens.init.test.js b/tests/supertokens.init.test.js index bf7bc8b..12314c9 100644 --- a/tests/supertokens.init.test.js +++ b/tests/supertokens.init.test.js @@ -3,7 +3,9 @@ process.env.JWT_SECRET = 'test-secret-st-init'; import { describe, it, expect, beforeEach } from 'vitest'; import { readFileSync } from 'node:fs'; import { buildProviders, resolvePublicOrigin, PROVIDER_IDS } from '../server/supertokens/providers.js'; -import { initSuperTokens, __isInitialised, __resetForTests } from '../server/supertokens/init.js'; +import { + initSuperTokens, __isInitialised, __resetForTests, isLoopback, +} from '../server/supertokens/init.js'; const CREDS = { GITHUB_CLIENT_ID: 'gh-id', @@ -12,6 +14,9 @@ const CREDS = { DISCORD_CLIENT_SECRET: 'dc-secret', PUBLIC_ORIGIN: 'https://rackstack.example.com', SUPERTOKENS_CONNECTION_URI: 'http://supertokens:3567', + // The core is not on loopback here, so a key is mandatory - see the + // 'refuses a non-loopback core with no API key' test below. + SUPERTOKENS_API_KEY: 'test-core-api-key', }; beforeEach(() => { __resetForTests(); }); @@ -130,13 +135,28 @@ describe('initSuperTokens containment', () => { it('mounts nothing extra on the app in passport mode', async () => { // app.js's middleware stack in passport mode must be what it was before - // v1.8 existed. Comparing route-layer counts is the cheapest way to see - // an accidental extra `app.use`. + // v1.8 existed. + // + // This used to assert `layerNames` did not contain 'middleware' or + // 'errorHandler' - which could never fail, because SuperTokens' express + // bindings are ANONYMOUS functions and appear on the stack as + // ''. The assertion passed whether or not they were mounted, + // i.e. it tested nothing at all. Found while fixing the mutation-detected + // gap that nothing asserted the POSITIVE case either + // (tests/supertokens.middleware.test.js now does, by count and by HTTP). + // + // Counting layers is the honest version: the two SuperTokens layers are + // exactly the difference between the two modes. const { buildApp } = await import('../server/app.js'); const app = await buildApp({ env: { ...process.env, AUTH_MODE: 'passport' } }); - const layerNames = app._router.stack.map((l) => l.name); - expect(layerNames).not.toContain('middleware'); - expect(layerNames).not.toContain('errorHandler'); + + const names = app._router.stack.map((l) => l.name); + // Recorded explicitly so an accidental `app.use` shows up as a diff here + // rather than passing silently. + expect(names).toEqual([ + 'query', 'expressInit', 'initialize', 'cookieParser', 'jsonParser', + 'router', 'router', 'serveStatic', 'bound dispatch', + ]); }); }); @@ -229,6 +249,40 @@ describe('initSuperTokens configuration errors', () => { .rejects.toThrow(/public origin/i); }); + it('refuses a non-loopback core with no API key', async () => { + // Review-found: the shipped compose file ran the core with no API_KEYS and + // published its port. A core without a key serves its whole API open, and + // POST /recipe/session mints a session for ANY userId - which the id + // mapping then turns into a real RackStack session for any SUPER_ADMIN_IDS + // value, without a single request reaching Express. + const { SUPERTOKENS_API_KEY: _omit, ...env } = CREDS; + await expect(initSuperTokens({ + env: { ...env, SUPERTOKENS_CONNECTION_URI: 'http://supertokens.example.com:3567' }, + mode: 'dual', + })).rejects.toThrow(/SUPERTOKENS_API_KEY/); + }); + + it('allows a loopback core without a key, since only this host can reach it', async () => { + // Requiring one there would only teach people to set a dummy value, which + // is worse than an exemption that is explained. + const { SUPERTOKENS_API_KEY: _omit, ...env } = CREDS; + await expect(initSuperTokens({ + env: { ...env, SUPERTOKENS_CONNECTION_URI: 'http://127.0.0.1:3567' }, + mode: 'dual', + })).resolves.toBe(true); + __resetForTests(); + }); + + it('does not treat a lookalike hostname as loopback', async () => { + // A substring check for '127.0.0.1' would wave through + // http://127.0.0.1.evil.com - the classic way this exemption goes wrong. + expect(isLoopback('http://127.0.0.1.evil.com:3567')).toBe(false); + expect(isLoopback('http://localhost.attacker.net:3567')).toBe(false); + expect(isLoopback('http://127.0.0.1:3567')).toBe(true); + expect(isLoopback('http://localhost:3567')).toBe(true); + expect(isLoopback('not a url')).toBe(false); + }); + it('refuses when no OAuth provider is configured', async () => { // Otherwise SuperTokens comes up healthy with no way for anyone to log // in - a failure that looks like success until a player tries. diff --git a/tests/supertokens.mapping.test.js b/tests/supertokens.mapping.test.js index 0797685..2172c07 100644 --- a/tests/supertokens.mapping.test.js +++ b/tests/supertokens.mapping.test.js @@ -307,6 +307,78 @@ describe('supertokens identity mapping - identity outcomes', () => { expect(created).toHaveLength(1); }); + it('resolves a DIVERGENT identity row to its stored user_id, not a reconstructed one', async () => { + // Mutation-found gap: replacing `return identity.user_id` with + // `` return `${thirdPartyId}:${thirdPartyUserId}` `` passed the entire + // suite, because every other fixture has the two equal. That is not a + // hypothetical shape - it is exactly what the shadow gate exists to hunt + // for, and tests/supertokens.shadow.test.js deliberately constructs one. + // So the code path that RESCUES such a player had no test at all, while + // the code that detects them had several. + const now = Date.now(); + await upsertUser({ + provider: 'discord', providerId: 'real-owner', username: 'realowner', avatarUrl: null, + }); + await putSave('discord:real-owner', { wafers: 4242, marker: 'divergent' }, 7); + + // An identity whose user_id is NOT `provider:provider_id`. upsertUser + // cannot produce this, which is the point - older code could. + if (driver.__backend === 'sqlite') { + driver.__raw.prepare( + 'INSERT INTO identities (provider, provider_id, user_id, created_at) VALUES (?, ?, ?, ?)', + ).run('discord', 'divergent-1', 'discord:real-owner', now); + } else { + await driver.__raw.query( + 'INSERT INTO identities (provider, provider_id, user_id, created_at) VALUES ($1, $2, $3, $4)', + ['discord', 'divergent-1', 'discord:real-owner', now], + ); + } + + const { session } = await createHarness().signInUpPOST(loginInput('discord', 'divergent-1')); + + // The stored user_id wins. Reconstructing would have produced + // 'discord:divergent-1' - a user that does not exist - and stranded them. + expect(session.getUserId()).toBe('discord:real-owner'); + expect(session.getUserId()).not.toBe('discord:divergent-1'); + + const save = await getSave(session.getUserId()); + expect(JSON.parse(save.data).marker).toBe('divergent'); + }); + + it('records the real SuperTokens id, and keeps recording it across re-logins', async () => { + // Review-found bug: on a returning login the core hands back the EXTERNAL + // id, and that value was being written back over the real SuperTokens id - + // so the column held our own users.id from the second login onwards. The + // old idempotence test could not see it: it asserted identity COUNT and + // createUserIdMapping CALL count, never re-read the column. + const h = createHarness(); + await h.signInUpPOST(loginInput('github', 'record-1')); + + const stId = [...h.core.mappings.keys()].find( + (k) => h.core.mappings.get(k) === 'github:record-1', + ); + expect(stId).toMatch(/^st-/); + expect((await getIdentity('github', 'record-1')).supertokens_user_id).toBe(stId); + + await h.signInUpPOST(loginInput('github', 'record-1')); + await h.signInUpPOST(loginInput('github', 'record-1')); + + const after = await getIdentity('github', 'record-1'); + expect(after.supertokens_user_id).toBe(stId); + // The specific corruption: never our own users.id. + expect(after.supertokens_user_id).not.toBe('github:record-1'); + }); + + it('refuses a provider user id that would make users.id ambiguous', async () => { + // users.id is `${provider}:${providerId}`, so the provider id becomes half + // of a composite key three foreign keys point at. Not reachable with + // today's numeric providers; asserted so the invariant is explicit before + // a non-numeric provider is ever added. + await expect(resolveExternalUserId({ + thirdPartyId: 'github', thirdPartyUserId: 'has:colon', + })).rejects.toThrow(/unexpected characters/i); + }); + it('refuses to issue a session when the core maps this login to a different player', async () => { // The one case where failing the login is the correct outcome: the core // and identities disagree about who this is, and guessing means serving diff --git a/tests/supertokens.middleware.test.js b/tests/supertokens.middleware.test.js index bc7c1f2..cfdeb35 100644 --- a/tests/supertokens.middleware.test.js +++ b/tests/supertokens.middleware.test.js @@ -53,6 +53,9 @@ await ensureConfig(); const ST_ENV = { ...process.env, SUPERTOKENS_CONNECTION_URI: 'http://supertokens.invalid:3567', + // Required for a non-loopback core - an unauthenticated one lets anyone who + // can reach it mint a session for any user id, SUPER_ADMIN_IDS included. + SUPERTOKENS_API_KEY: 'test-core-api-key', PUBLIC_ORIGIN: 'https://rackstack.example.com', }; @@ -155,6 +158,111 @@ describe('passport route gating', () => { }); }); +describe('the SuperTokens middleware is actually mounted', () => { + // Mutation-found gap: deleting `middleware()` or `errorHandler()` from + // app.js passed the ENTIRE suite. The only related assertion was one-sided - + // passport mode must NOT have them - with no positive counterpart, and no + // test anywhere issued a request against a SuperTokens endpoint. Meanwhile + // the runbook and authentication-methods.md both assert "the server side is + // complete, the middleware serves /auth/authorisationurl and + // /auth/signinup". Since the client never calls those, a broken mount would + // also be invisible in production. + + it('adds exactly two layers over passport mode - the middleware and the error handler', () => { + // Counted, not matched by name: both are anonymous functions on the stack, + // so the pre-existing `expect(layerNames).not.toContain('middleware')` + // assertion in supertokens.init.test.js could never have failed - it was + // vacuous in the strongest sense. The delta is what pins errorHandler + // specifically, since the behavioural assertions below only exercise the + // middleware. + expect(apps.dual._router.stack.length).toBe(apps.passport._router.stack.length + 2); + }); + + it('serves /auth/authorisationurl as JSON in dual mode - the endpoint the docs promise', async () => { + // Content-type is the signal, not the status. Offline (no core reachable) + // this answers 400 rather than 200, but a JSON body at all proves + // SuperTokens handled the request; an unmounted middleware falls through + // to the SPA, which answers 200 text/html - a status check alone would + // therefore have read the BROKEN case as healthier than the working one. + const res = await request(apps.dual).get('/auth/authorisationurl?thirdPartyId=github'); + expect(res.headers['content-type'] ?? '').toContain('application/json'); + }); + + it('serves the Session recipe endpoints in dual mode', async () => { + // A second, independent endpoint from a different recipe, so the mount is + // not proven by ThirdParty alone. + const res = await request(apps.dual).post('/auth/session/refresh'); + expect(res.headers['content-type'] ?? '').toContain('application/json'); + expect(res.status).toBe(401); + }); + + it('serves neither in passport mode', async () => { + // Containment: both must fall through to the SPA or 404, never JSON. + const auth = await request(apps.passport).get('/auth/authorisationurl?thirdPartyId=github'); + expect(auth.headers['content-type'] ?? '').not.toContain('application/json'); + + const refresh = await request(apps.passport).post('/auth/session/refresh'); + expect(refresh.headers['content-type'] ?? '').not.toContain('application/json'); + }); + + it('removes the stock POST /auth/signout, which would half-log-out a dual-stack user', async () => { + // The Session recipe registers /auth/signout automatically. It revokes the + // SuperTokens session and leaves the legacy JWT cookie, so requireAuth's + // fallback re-authenticates the "logged out" user on the next request - + // exactly the half-logout authRoutes.js exists to prevent, and the default + // path the SuperTokens frontend SDK's signOut() would have used. + const res = await request(apps.dual) + .post('/auth/signout') + .set('Cookie', legacyCookie(player)); + + expect(res.status).toBe(404); + + // And the cookie is untouched by it, which is why leaving it registered + // would have been unsafe. + const stillWorks = await request(apps.dual).get('/api/me').set('Cookie', legacyCookie(player)); + expect(stillWorks.status).toBe(200); + }); +}); + +describe('logout revokes both stacks', () => { + it('revokes the SuperTokens session, not just the legacy cookie', async () => { + // Mutation-found gap: wrapping the revocation in `if (false)` passed + // everything. The plan's Task 4 Step 2 is explicit that logout must clear + // BOTH, and the unverified half was the security-relevant one - a logout + // that leaves a live session is worse than one that fails loudly. + const Session = await loadSessionRecipe(); + let revoked = false; + const spy = vi.spyOn(Session, 'getSession').mockResolvedValue({ + getUserId: () => 'github:chain-1', + revokeSession: async () => { revoked = true; }, + }); + + try { + const res = await request(apps.dual).post('/auth/logout').set('Cookie', legacyCookie(player)); + expect(res.status).toBe(200); + expect(revoked, 'logout did not revoke the SuperTokens session').toBe(true); + } finally { + spy.mockRestore(); + } + }); + + it('still clears the legacy cookie when revocation throws', async () => { + // Best-effort by design: a SuperTokens session that cannot even be read is + // not one this request can revoke, and failing the whole logout would + // leave the user MORE logged in than reporting success does. + const Session = await loadSessionRecipe(); + const spy = vi.spyOn(Session, 'getSession').mockRejectedValue(new Error('core unreachable')); + + try { + const res = await request(apps.dual).post('/auth/logout').set('Cookie', legacyCookie(player)); + expect(res.status).toBe(200); + expect((res.headers['set-cookie'] ?? []).join(';')).toContain(COOKIE_NAME); + } finally { + spy.mockRestore(); + } + }); +}); + describe('the SuperTokens branch of the chain', () => { it('is live once SuperTokens is initialised', () => { // Guards every stub below: if init had silently not happened, the stubs diff --git a/tests/supertokens.security.test.js b/tests/supertokens.security.test.js index d7cb8a3..a113f02 100644 --- a/tests/supertokens.security.test.js +++ b/tests/supertokens.security.test.js @@ -41,16 +41,25 @@ describe('rejectRawOAuthTokens (authentication bypass guard)', () => { const passThrough = { signInUpPOST: async (input) => ({ status: 'OK', echoed: input }) }; it('rejects a request that submits raw oAuthTokens', async () => { + // GENERAL_ERROR, not a thrown Error. A throw reached Express's default + // handler as a 500, which reads as transient - so a client would retry a + // request that can never succeed. GENERAL_ERROR is the SDK's own contract + // for "refused, do not retry". What matters either way is that the stock + // implementation is never reached. const guarded = rejectRawOAuthTokens(passThrough); - await expect(guarded.signInUpPOST({ + const res = await guarded.signInUpPOST({ oAuthTokens: { access_token: 'gho_stolen_from_another_app' }, - })).rejects.toThrow(/redirect-URI flow/); + }); + expect(res.status).toBe('GENERAL_ERROR'); + expect(res.status).not.toBe('OK'); + expect(res.echoed).toBeUndefined(); + expect(res.message).toMatch(/redirect-URI flow/); }); it('names why, not merely that it refused', async () => { const guarded = rejectRawOAuthTokens(passThrough); - await expect(guarded.signInUpPOST({ oAuthTokens: { access_token: 'x' } })) - .rejects.toThrow(/issued to this application/); + const res = await guarded.signInUpPOST({ oAuthTokens: { access_token: 'x' } }); + expect(res.message).toMatch(/issued to this application/); }); it('lets the legitimate redirect-URI flow through untouched', async () => { diff --git a/tests/supertokens.shadow.test.js b/tests/supertokens.shadow.test.js index 18bca57..c43141e 100644 --- a/tests/supertokens.shadow.test.js +++ b/tests/supertokens.shadow.test.js @@ -1,15 +1,29 @@ -// Shadow mode is the gate that clears the cutover, so the two things it must -// never do are: report PASS when it should not, and write anything. +// Shadow mode is the gate that clears the cutover, so the things it must never +// do are: report PASS when it should not, miss a corruption it claims to cover, +// and write anything. // -// The no-write property gets a snapshot of the entire identities table taken -// around a run and compared byte-for-byte, rather than a check that the one -// row under test is unchanged. The point of shadow mode is that it can be run -// against a live production database while people are playing, and "the row I -// looked at is fine" is not that guarantee. +// The v1.8 final review found it doing all three. The tests below are written +// against those specific failures rather than around them: +// +// - The no-write property is now asserted against the OPERATOR ENTRY POINT in +// a fresh process, on a pre-v1.7 database. The old test snapshotted the +// table from inside a process whose db facade had *already* migrated it, so +// the very writes it existed to catch had happened before it looked. +// - Orphaned identity rows (user_id pointing at no user) get an explicit +// outcome, because the old enumeration walked users -> identities and +// could not see them at all. +// - A run of nothing but new players must not pass. import { describe, it, expect, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import Database from 'better-sqlite3'; import { provisionDatabase } from './helpers/backend.js'; +const execFileAsync = promisify(execFile); const provisioned = await provisionDatabase(); const dbMod = await import('../server/db.js'); @@ -17,45 +31,39 @@ const { driver, upsertUser } = dbMod; const { compareIdentity, summarise, formatSummary, formatResult, createShadowRun, - auditStoredIdentities, - SHADOW_MATCH, SHADOW_MISMATCH, SHADOW_NO_IDENTITY, + auditStoredIdentities, classifyIdentityRow, + SHADOW_MATCH, SHADOW_MISMATCH, SHADOW_NO_IDENTITY, SHADOW_ORPHAN, } = await import('../server/supertokens/shadow.js'); +const scratchDirs = []; + afterAll(async () => { if (driver.__backend === 'pg') await driver.__raw.end(); await provisioned.cleanup(); + for (const d of scratchDirs) rmSync(d, { recursive: true, force: true }); }); -async function snapshotIdentities() { - const sql = 'SELECT provider, provider_id, user_id, supertokens_user_id, created_at, last_login_at ' - + 'FROM identities ORDER BY provider, provider_id'; - const rows = driver.__backend === 'sqlite' - ? driver.__raw.prepare(sql).all() - : (await driver.__raw.query(sql)).rows; - return JSON.stringify(rows); -} +// ---------------------------------------------------------------- live check -describe('shadow-mode comparison', () => { +describe('the live per-login comparison (runbook C4)', () => { it('reports a match when the stored identity is what SuperTokens implies', async () => { await upsertUser({ provider: 'github', providerId: '37058311', username: 'nec', avatarUrl: null, }); - - const result = await compareIdentity({ thirdPartyId: 'github', thirdPartyUserId: '37058311' }); + const result = await compareIdentity( + { thirdPartyId: 'github', thirdPartyUserId: '37058311' }, dbMod, + ); expect(result.outcome).toBe(SHADOW_MATCH); - expect(result.expectedUserId).toBe('github:37058311'); expect(result.actualUserId).toBe('github:37058311'); }); it('reports a mismatch when identities points at a different user', async () => { - // The shape that would actually bite: an identity row whose user_id is not - // `provider:provider_id`. Written directly, because upsertUser cannot - // produce it - which is the point, since the rows this gate examines were - // written by older code, not by today's. const now = Date.now(); await upsertUser({ provider: 'discord', providerId: 'legacy-owner', username: 'legacy', avatarUrl: null, }); + // Written directly, because upsertUser cannot produce this shape - which is + // the point: the rows this gate examines were written by older code. if (driver.__backend === 'sqlite') { driver.__raw.prepare( 'INSERT INTO identities (provider, provider_id, user_id, created_at) VALUES (?, ?, ?, ?)', @@ -67,106 +75,120 @@ describe('shadow-mode comparison', () => { ); } - const result = await compareIdentity({ thirdPartyId: 'discord', thirdPartyUserId: 'odd-shape' }); + const result = await compareIdentity( + { thirdPartyId: 'discord', thirdPartyUserId: 'odd-shape' }, dbMod, + ); expect(result.outcome).toBe(SHADOW_MISMATCH); expect(result.expectedUserId).toBe('discord:odd-shape'); expect(result.actualUserId).toBe('discord:legacy-owner'); }); it('reports no-identity for a player who has never logged in, and does not call it a mismatch', async () => { - const result = await compareIdentity({ thirdPartyId: 'github', thirdPartyUserId: 'never-seen' }); + const result = await compareIdentity( + { thirdPartyId: 'github', thirdPartyUserId: 'never-seen' }, dbMod, + ); expect(result.outcome).toBe(SHADOW_NO_IDENTITY); expect(result.actualUserId).toBeNull(); - // Conflating this with a mismatch would make the gate unreadable on any - // server that has ever had a new signup. expect(result.outcome).not.toBe(SHADOW_MISMATCH); }); - it('writes absolutely nothing - the whole identities table is unchanged', async () => { - // Table-wide, not row-wide. Shadow mode's entire value is that it is safe - // to point at production while people are playing. - const before = await snapshotIdentities(); - - const run = createShadowRun({ log: () => {} }); + it('collects a run end to end', async () => { + const logged = []; + const run = createShadowRun({ db: dbMod, log: (line) => logged.push(line) }); await run.record({ thirdPartyId: 'github', thirdPartyUserId: '37058311' }); await run.record({ thirdPartyId: 'discord', thirdPartyUserId: 'odd-shape' }); - await run.record({ thirdPartyId: 'github', thirdPartyUserId: 'never-seen' }); - expect(await snapshotIdentities()).toBe(before); + expect(logged).toHaveLength(2); + const summary = run.summary(); + expect(summary.matched).toBe(1); + expect(summary.mismatched).toBe(1); + expect(summary.passed).toBe(false); + expect(run.report()).toContain('GATE: FAIL'); }); }); -describe('the offline audit (the form the gate actually runs before cutover)', () => { - it('audits every stored identity without a single login happening', async () => { - // This is what `npm run shadow:check` does against the owner's export. It - // needs no SuperTokens core, no login, and no cutover - which is the point, - // since a gate you can only open after walking through the door is not a - // gate. - const results = await auditStoredIdentities(); - expect(results.length).toBeGreaterThan(0); +// ------------------------------------------------------------- offline audit - // The deliberately-odd row inserted above must be caught. - const odd = results.find((r) => r.thirdPartyUserId === 'odd-shape'); - expect(odd).toBeDefined(); - expect(odd.outcome).toBe(SHADOW_MISMATCH); - expect(odd.actualUserId).toBe('discord:legacy-owner'); +describe('the offline audit (the gate itself)', () => { + const rows = [ + { provider: 'github', provider_id: '1', user_id: 'github:1', user_exists: 1 }, + { provider: 'github', provider_id: '2', user_id: 'github:other', user_exists: 1 }, + { provider: 'discord', provider_id: '3', user_id: 'discord:3', user_exists: 0 }, + ]; - // ...and the normal rows must not be. - const good = results.find((r) => r.thirdPartyUserId === '37058311'); - expect(good.outcome).toBe(SHADOW_MATCH); - }); - - it('writes nothing while auditing', async () => { - const before = await snapshotIdentities(); - await auditStoredIdentities(); - expect(await snapshotIdentities()).toBe(before); + it('classifies match, mismatch and orphan from stored rows', async () => { + const results = await auditStoredIdentities({ + readAllIdentities: async () => rows, + }); + expect(results.map((r) => r.outcome)).toEqual([SHADOW_MATCH, SHADOW_MISMATCH, SHADOW_ORPHAN]); }); - it('never reports no-identity - every row it reads exists by construction', async () => { - // Distinguishes the audit from the per-login path. Reading rows out of the - // table cannot produce a "this player does not exist" outcome, so a - // no-identity here would mean the enumeration had gone wrong. - const results = await auditStoredIdentities(); - expect(results.some((r) => r.outcome === SHADOW_NO_IDENTITY)).toBe(false); - }); + it('SEES an identity row orphaned from users, and fails the gate on it', async () => { + // The corruption the old enumeration could not reach. Such a player has a + // login method but no account: resolveExternalUserId hands back a users.id + // with no row, requireAuth refuses the session, and they can never log in. + const orphanOnly = [{ + provider: 'github', provider_id: 'ghost', user_id: 'github:ghost', user_exists: 0, + }]; + const summary = summarise(await auditStoredIdentities({ + readAllIdentities: async () => orphanOnly, + })); - it('fails the gate on a real database containing a bad row', async () => { - // End to end: audit this database, summarise, and confirm the operator is - // told not to cut over - and told which pair is the problem. - const summary = summarise(await auditStoredIdentities()); + expect(summary.orphaned).toBe(1); expect(summary.passed).toBe(false); const report = formatSummary(summary); expect(report).toContain('GATE: FAIL'); - expect(report).toContain('odd-shape'); + expect(report).toContain('cannot log in'); + expect(report).toContain('github:ghost'); + }); + + it('takes its rows from an injected reader, never from the db facade', async () => { + // The structural half of the read-only guarantee: shadow.js has no import + // that could reach a connection which runs applySchema. If this ever needs + // a real driver to work, the safety property has been lost. + let called = false; + await auditStoredIdentities({ + readAllIdentities: async () => { called = true; return []; }, + }); + expect(called).toBe(true); }); }); -describe('the gate', () => { +describe('the gate arithmetic', () => { const match = { outcome: SHADOW_MATCH, thirdPartyId: 'github', thirdPartyUserId: '1', expectedUserId: 'github:1', actualUserId: 'github:1' }; const mismatch = { outcome: SHADOW_MISMATCH, thirdPartyId: 'github', thirdPartyUserId: '2', expectedUserId: 'github:2', actualUserId: 'github:other' }; + const orphan = { outcome: SHADOW_ORPHAN, thirdPartyId: 'github', thirdPartyUserId: '4', expectedUserId: 'github:4', actualUserId: 'github:4' }; const missing = { outcome: SHADOW_NO_IDENTITY, thirdPartyId: 'github', thirdPartyUserId: '3', expectedUserId: 'github:3', actualUserId: null }; - it('passes only on 100% of comparable logins', () => { + it('passes only on 100% of comparable identities', () => { expect(summarise([match, match]).passed).toBe(true); expect(summarise([match, mismatch]).passed).toBe(false); - // A single mismatch among many matches is still a fail - 99% is a player. expect(summarise([match, match, match, match, mismatch]).passed).toBe(false); + expect(summarise([match, orphan]).passed).toBe(false); }); it('does NOT pass an empty run', () => { - // The vacuous-pass trap. An empty run has a 100% match rate by arithmetic, - // and a gate that reports PASS because it compared nothing manufactures - // exactly the false confidence it exists to prevent. const summary = summarise([]); expect(summary.passed).toBe(false); expect(summary.matchRate).toBeNull(); expect(formatSummary(summary)).toContain('NOT RUN'); - expect(formatSummary(summary)).not.toContain('PASS -'); + }); + + it('does NOT pass a run of nothing but new players', () => { + // The vacuous pass the v1.8 final review found: `passed` gated on the + // TOTAL result count rather than the comparable one, so three no-identity + // results printed "100% of comparable logins matched" having compared + // none. Realistic, too - it is what an operator shadow-testing with a + // fresh throwaway account produces. + const summary = summarise([missing, missing, missing]); + expect(summary.comparable).toBe(0); + expect(summary.matchRate).toBeNull(); + expect(summary.passed).toBe(false); + expect(formatSummary(summary)).toContain('NOT RUN'); + expect(formatSummary(summary)).not.toContain('GATE: PASS'); }); it('does not let new players drag the rate down or prop it up', () => { - // no-identity rows are excluded from the rate entirely: they are neither - // evidence for nor against the id-shape assumption. const summary = summarise([match, missing, missing]); expect(summary.matchRate).toBe(1); expect(summary.noIdentity).toBe(2); @@ -177,39 +199,169 @@ describe('the gate', () => { expect(failing.passed).toBe(false); }); - it('names every mismatching pair in the report rather than burying a percentage', () => { - const report = formatSummary(summarise([match, mismatch])); + it('names every offending pair rather than burying a percentage', () => { + const report = formatSummary(summarise([match, mismatch, orphan])); expect(report).toContain('GATE: FAIL'); expect(report).toContain('github:2'); expect(report).toContain('github:other'); expect(report).toContain('wrong save'); + expect(report).toContain('ORPHANS'); }); - it('says PASS in words, not just as a number', () => { - const report = formatSummary(summarise([match, match])); - expect(report).toContain('GATE: PASS'); - expect(report).toContain('100.00%'); - }); - - it('logs one legible line per login', () => { + it('logs one legible line per outcome', () => { expect(formatResult(match)).toContain('MATCH'); - expect(formatResult(mismatch)).toContain('MISMATCH'); expect(formatResult(mismatch)).toContain('WRONG save'); + expect(formatResult(orphan)).toContain('cannot log in'); expect(formatResult(missing)).toContain('not a failure'); }); - it('collects a run end to end', async () => { - const logged = []; - const run = createShadowRun({ log: (line) => logged.push(line) }); - await run.record({ thirdPartyId: 'github', thirdPartyUserId: '37058311' }); - await run.record({ thirdPartyId: 'discord', thirdPartyUserId: 'odd-shape' }); + it('classifyIdentityRow treats a missing user as orphan even when the id shape matches', () => { + // Ordering inside the classifier matters: a row can be BOTH well-shaped and + // orphaned, and the orphan is the more serious fact. + expect(classifyIdentityRow({ + provider: 'github', provider_id: 'x', user_id: 'github:x', user_exists: 0, + }).outcome).toBe(SHADOW_ORPHAN); + }); +}); - expect(logged).toHaveLength(2); - const summary = run.summary(); - expect(summary.total).toBe(2); - expect(summary.matched).toBe(1); - expect(summary.mismatched).toBe(1); - expect(summary.passed).toBe(false); - expect(run.report()).toContain('GATE: FAIL'); +// ------------------------------------------------- the no-write guarantee + +describe('npm run shadow:check is genuinely read-only', () => { + // Asserted against the OPERATOR ENTRY POINT, in a FRESH PROCESS, on a + // PRE-v1.7 database - all three of which matter. + // + // The old test could not fail: it snapshotted the identities table from + // inside a process whose db facade had already been imported, so applySchema + // had already run and any damage predated the snapshot. And it ran against a + // current-shape database, where applySchema mostly no-ops - while the + // documented primary use is auditing a RESTORED PRE-v1.7 EXPORT, which is + // exactly the case that triggers dedupeUsernames and the users rebuild. + function makePreV17Database() { + const dir = mkdtempSync(path.join(tmpdir(), 'rackstack-shadow-')); + scratchDirs.push(dir); + const file = path.join(dir, 'legacy.db'); + const raw = new Database(file); + raw.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, provider TEXT NOT NULL, provider_id TEXT NOT NULL, + username TEXT, avatar_url TEXT, created_at INTEGER NOT NULL, + UNIQUE(provider, provider_id) + ); + CREATE TABLE saves ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + data TEXT NOT NULL, last_save INTEGER NOT NULL + ); + -- Case-variant usernames: legal before v1.7's unique index existed, and + -- precisely what dedupeUsernames would rename. + INSERT INTO users VALUES ('github:1','github','1','nec',NULL,1); + INSERT INTO users VALUES ('discord:2','discord','2','NEC',NULL,2); + INSERT INTO saves VALUES ('github:1','{"wafers":42}',1); + `); + raw.close(); + return { dir, file }; + } + + function snapshot(file) { + const raw = new Database(file, { readonly: true }); + try { + return JSON.stringify({ + users: raw.prepare('SELECT id, username FROM users ORDER BY id').all(), + tables: raw.prepare( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", + ).all().map((r) => r.name), + }); + } finally { + raw.close(); + } + } + + it('does not rename users, create tables, or leave WAL sidecars behind', async () => { + const { dir, file } = makePreV17Database(); + const before = snapshot(file); + + // A real subprocess: the writes happened at MODULE-EVALUATION time, so + // nothing short of a separate process actually reproduces the bug. + const result = await execFileAsync( + process.execPath, + [path.join(process.cwd(), 'server/supertokens/shadowCheck.js')], + { env: { ...process.env, DB_PATH: file, DATABASE_URL: '' }, cwd: process.cwd() }, + ).catch((e) => e); // non-zero exit is expected - there are no identities + + const after = snapshot(file); + expect(after, `shadow:check mutated the database.\n${result.stdout ?? ''}${result.stderr ?? ''}`) + .toBe(before); + + // The pre-v1.7 shape must still be intact: no identities table conjured, + // no users rebuild, and the colliding username untouched. + const parsed = JSON.parse(after); + expect(parsed.tables).toEqual(['saves', 'users']); + expect(parsed.users.find((u) => u.id === 'discord:2').username).toBe('NEC'); + + // journal_mode = WAL is itself a write to the file, and leaves sidecars. + expect(existsSync(`${file}-wal`)).toBe(false); + expect(readdirSync(dir).sort()).toEqual(['legacy.db']); + }); + + it('says so plainly when handed a pre-v1.7 export, rather than a raw SQL error', async () => { + // Restoring the WRONG export is an easy mistake - the runbook just says + // "a restored export" - and a pre-v1.7 one has no identities table at all. + // The natural symptom would be "no such table: identities" from inside a + // SELECT, which tells an operator nothing about what to do next. + const { file } = makePreV17Database(); + const result = await execFileAsync( + process.execPath, + [path.join(process.cwd(), 'server/supertokens/shadowCheck.js')], + { env: { ...process.env, DB_PATH: file, DATABASE_URL: '' }, cwd: process.cwd() }, + ).catch((e) => e); + + expect(result.code ?? 0).not.toBe(0); + expect(`${result.stderr ?? ''}`).toContain('predates the v1.7 auth split'); + }); + + it('reports NOT RUN, non-zero, on a v1.7-shaped database with no identity rows', async () => { + // The empty-but-valid case: the gate must not read "clean" for a database + // it compared nothing in. Distinct from the pre-v1.7 case above. + const dir = mkdtempSync(path.join(tmpdir(), 'rackstack-shadow-')); + scratchDirs.push(dir); + const file = path.join(dir, 'empty.db'); + const raw = new Database(file); + raw.exec(` + CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT, created_at INTEGER NOT NULL); + CREATE TABLE identities ( + provider TEXT NOT NULL, provider_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id), + supertokens_user_id TEXT UNIQUE, created_at INTEGER NOT NULL, last_login_at INTEGER, + PRIMARY KEY (provider, provider_id) + ); + `); + raw.close(); + + const result = await execFileAsync( + process.execPath, + [path.join(process.cwd(), 'server/supertokens/shadowCheck.js')], + { env: { ...process.env, DB_PATH: file, DATABASE_URL: '' }, cwd: process.cwd() }, + ).catch((e) => e); + + expect(result.code ?? 0).not.toBe(0); + expect(`${result.stdout ?? ''}`).toContain('NOT RUN'); + expect(`${result.stdout ?? ''}`).not.toContain('GATE: PASS'); + }); + + it('refuses a database that does not exist instead of auditing an empty one it just created', async () => { + // A typo'd DB_PATH used to create a fresh file, find nothing, and report - + // which reads as "clean" to a tired operator. fileMustExist turns that into + // an error. + const dir = mkdtempSync(path.join(tmpdir(), 'rackstack-shadow-')); + scratchDirs.push(dir); + const missing = path.join(dir, 'nope.db'); + + const result = await execFileAsync( + process.execPath, + [path.join(process.cwd(), 'server/supertokens/shadowCheck.js')], + { env: { ...process.env, DB_PATH: missing, DATABASE_URL: '' }, cwd: process.cwd() }, + ).catch((e) => e); + + expect(result.code ?? 0).not.toBe(0); + expect(existsSync(missing)).toBe(false); }); }); From 79042818be7a5e5091ea06d90622bc6fefe4690b Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Fri, 7 Aug 2026 00:00:18 -0400 Subject: [PATCH 14/14] Correct the docs the review found overstating what was true Two of them were promising the opposite of what the code did. The runbook told operators in bold that `npm run shadow:check` "issues nothing but SELECTs" and was "safe to run against a restored export on a laptop". It migrated and rewrote any database it touched, and the restored-export case was the one that triggered it. The claim is now true (the audit opens its own read-only connection) and the doc says plainly that it was not true before. The compose file, runbook and Unraid template all walked an operator into running the SuperTokens core unauthenticated with its port published - a core that will mint a session for any user id, SUPER_ADMIN_IDS included. All three now require an API key and an unpublished port, and explain why. Also: - README, .env.example and unraid-template.xml listed `supertokens` as a neutral selectable mode. It is unusable - the client points its login buttons at passport routes that mode does not register - and the README is where an operator acts from. All three now say so. - Added SUPERTOKENS_API_KEY and PUBLIC_ORIGIN to the Unraid template. The runbook already told operators to set PUBLIC_ORIGIN as the fix for a documented boot failure, but there was no field for it. - interface.md justified the missing-row no-op with a permanent-lockout scenario that cannot happen: linkExternalUserId reads getUserIdMapping first and takes the already-mapped branch on retry. The no-op is still right; the stated mechanism was fiction and would have sent the next maintainer hunting a broken retry path. - README's `${DATABASE_URL:-...}` corrected to the non-colon form, which is what compose actually uses and what makes the documented rollback work. - Stale test counts in the design spec. - The plan gains a full findings section for the review, including the one deviation that had been missing from it (mountSuperTokens was never built). Verified: 614 tests on SQLite, 637 on Postgres, 39 smoke assertions, a real boot in all three AUTH_MODE values, and the new guard confirmed to refuse a remote core with no API key. Co-Authored-By: Claude Opus 5 --- .env.example | 16 ++- CHANGELOG.md | 25 ++++ README.md | 20 +++- .../plans/2026-08-06-v1.8-supertokens.md | 111 ++++++++++++++++++ .../2026-08-01-postgres-supertokens-design.md | 7 +- docs/supertokens-rollout-runbook.md | 59 ++++++++-- server/db/interface.md | 16 ++- unraid-template.xml | 4 +- 8 files changed, 233 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index c41dab3..1cd6066 100644 --- a/.env.example +++ b/.env.example @@ -51,7 +51,13 @@ SUPER_ADMIN_IDS= # dual Both login paths work; a session from either is accepted. # This is where the rollout happens. Existing login cookies # keep working for their full 90-day life. -# supertokens SuperTokens only; the old OAuth routes are switched off. +# supertokens NOT USABLE YET. SuperTokens only; the old OAuth routes are +# switched off - but the client still points its login buttons +# at those routes and has no SuperTokens login flow, so the +# buttons silently do nothing and nobody can sign in. Existing +# sessions keep working, which is what makes it easy to miss. +# `dual` is the intended resting state for v1.8. See +# docs/authentication-methods.md Phase 5. # # Rolling back is setting this back to passport (or blanking it) and # restarting. Existing login cookies stay valid through every transition in @@ -68,7 +74,13 @@ SUPER_ADMIN_IDS= # separate database on the same Postgres server, never at the rackstack one. # SUPERTOKENS_CONNECTION_URI=http://supertokens:3567 -# Optional API key, if you configured one on the SuperTokens core. +# API key for the SuperTokens core. NOT optional for any core that is not on +# loopback: a core without one serves its whole API unauthenticated, and that +# API mints a session for ANY user id - including every value in +# SUPER_ADMIN_IDS - without a single request reaching RackStack. The server +# refuses to start in dual/supertokens mode if the core is remote and this is +# unset. Generate with `openssl rand -hex 32`, and set the same value as +# API_KEYS on the core container. Do not publish the core's port either. # SUPERTOKENS_API_KEY= # The public origin this server is reached at, e.g. https://rackstack.example.com diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac8693..3160901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,31 @@ Everything below is inert until an operator opts in. redirect widening (additive and reversible — nothing is removed, so passport keeps working), standing up the core, the shadow gate, cutover and rollback. +**Hardening from the pre-merge review.** A whole-branch security and code +review was run before merge. Everything below was found and fixed while +`AUTH_MODE` still defaulted to `passport`, so none of it was ever live: + +- An **authentication bypass** in SuperTokens' stock `signinup` endpoint, which + accepted a caller-supplied OAuth token as proof of identity. Any GitHub token + able to read `/user` — including one from an unrelated app, or a leaked PAT — + would have authenticated as its owner. Only the browser redirect flow is + accepted now. +- The **SuperTokens core shipped unauthenticated with its port published.** An + open core will mint a session for any user id, `SUPER_ADMIN_IDS` included, + without any request reaching RackStack. The port is no longer published, the + image is pinned, and the server refuses to start against a remote core with + no `SUPERTOKENS_API_KEY`. +- **`npm run shadow:check` was not read-only** despite saying so: it ran the + schema migration on load, which on SQLite renames colliding usernames and + rebuilds a table. Pointed at a pre-v1.7 export it quietly rewrote it. It now + opens its own read-only connection and issues one SELECT. +- The gate could not see **identity rows orphaned from `users`** (a player who + can never log in) and reported PASS for a run that compared nothing. +- **`POST /auth/signout`** revoked the SuperTokens session but left the legacy + cookie, so the "logged out" user stayed authenticated. Removed; + `/auth/logout` clears both. +- Two **simultaneous first logins** raced on Postgres and failed the login. + **Not yet run anywhere.** Shadow mode has not been run against production identities, and no cutover has happened. diff --git a/README.md b/README.md index 54f10d1..18367e4 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ variable actually lives for your deployment: | Deployment | Where to blank `DATABASE_URL` | |---|---| | Unraid / plain `docker run` | The container's Variable in the Unraid UI (or the `-e` flag) | -| Docker Compose | `.env` — `docker-compose.yml` reads it via `${DATABASE_URL:-...}` | +| Docker Compose | `.env` — `docker-compose.yml` reads it via `${DATABASE_URL-...}` (no colon, so `DATABASE_URL=` means "blank", not "unset" — that is what makes the documented rollback work) | | Local `npm start` | `.env` | ### Authentication stack (`AUTH_MODE`) @@ -235,7 +235,7 @@ and the SuperTokens SDK is not even loaded. |---|---| | *(blank)* or `passport` | Default. Exactly as before; SuperTokens is not initialised. | | `dual` | Both login paths live, sessions from either accepted. Where the rollout happens. | -| `supertokens` | SuperTokens only; the legacy OAuth routes are not registered. | +| `supertokens` | ⚠️ **Not usable yet** — SuperTokens only; the legacy OAuth routes are not registered, and the client has no SuperTokens login flow, so **nobody can log in**. See below. | Two properties worth knowing before you touch it: @@ -247,10 +247,26 @@ Two properties worth knowing before you touch it: looking like a finished rollout — the kind of thing you'd discover weeks later, from the wrong symptom. +- **`dual` is the intended resting state.** `supertokens`-only mode is *not* + usable yet: `client/src/Login.jsx` points its buttons at the passport routes, + which that mode does not register, so they silently do nothing and no one can + sign in. Existing sessions keep working via the JWT fallback, which is what + makes it easy to miss. There is no token refresh in the client either. Both + are frontend work that has not been started — see + [`docs/authentication-methods.md`](./docs/authentication-methods.md) Phase 5. + `SUPERTOKENS_CONNECTION_URI` points at the SuperTokens core container and is read only in `dual`/`supertokens`. That core needs its **own** database on your Postgres server, separate from the rackstack one. +**Set `SUPERTOKENS_API_KEY`, and do not publish the core's port.** A +SuperTokens core with no API key serves its entire API unauthenticated, and +that API can mint a session for *any* user id — including every value in +`SUPER_ADMIN_IDS`, without any request reaching RackStack. The server refuses +to start in `dual`/`supertokens` if the core is not on loopback and no key is +set. Generate one with `openssl rand -hex 32` and set it as `API_KEYS` on the +core and `SUPERTOKENS_API_KEY` here. + Full walkthrough — including the OAuth redirect-URL change that has to happen *before* `dual`, and the verification gate before cutover — is in [`docs/supertokens-rollout-runbook.md`](./docs/supertokens-rollout-runbook.md). diff --git a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md index 7e4f0d3..87d2f22 100644 --- a/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md +++ b/docs/superpowers/plans/2026-08-06-v1.8-supertokens.md @@ -570,6 +570,117 @@ SuperTokens core ever run against this code outside tests, and runbook had to be corrected for implying more had been rehearsed than had; this one starts there instead. +### Whole-branch final review (2026-08-06) — findings and fixes + +Three parallel reviewers (security / db+mapping / tests+docs) against +`f718dab..8a1f224`. Verdicts were *with fixes*, **No**, *with fixes*. It found +things every per-task review had missed, including two that contradicted +guarantees these documents asserted in bold. + +**CRITICAL — `npm run shadow:check` was not read-only.** It imported +`server/db/index.js`, whose top-level await builds a driver, which runs +`applySchema`: on SQLite that means `journal_mode = WAL`, `dedupeUsernames` +(an `UPDATE users SET username`, renaming case-colliding accounts) and +`migrateIdentities` (a `users` rebuild with foreign keys off). A reviewer +pointed it at a pre-v1.7 export and watched a user get renamed and seven +tables appear — then it printed `GATE: PASS`. + +The damage landed hardest in the *documented primary use*: auditing a restored +export. A healthy v1.8 database mostly no-ops, so the one case the runbook +recommended was the one that mutated. The task-5 note in this plan and the +runbook both promised, in bold, that it issued nothing but SELECTs. + +Fixed by giving the audit its own connection — `better-sqlite3` with +`readonly: true, fileMustExist: true`, or a `pg` client inside a +`BEGIN TRANSACTION READ ONLY` — and injecting the reader, so `shadow.js` has no +import that could reach a migrating connection even by accident. The no-write +test now runs the real entry point in a **subprocess** against a **pre-v1.7** +database and diffs a full snapshot; the old test could not fail, because it +snapshotted from inside a process whose facade had already migrated. + +**CRITICAL — the SuperTokens core shipped unauthenticated on a published +port.** `docker-compose.yml` set no `API_KEYS` and published `3567:3567`. A +core without a key serves its entire API open, and `POST /recipe/session` mints +a session for *any* `userId` — which the id mapping turns into a valid +RackStack session for any `SUPER_ADMIN_IDS` value, without a single request +reaching Express. Port unpublished, image pinned off `:latest`, and +`initSuperTokens` now refuses a non-loopback core with no key. + +Enforced in server code rather than in Compose deliberately: Compose +interpolates the whole file *before* filtering by profile, so a `${VAR:?}` +there would break `docker compose up` for every deployment that never touches +SuperTokens — a worse regression than the bug. The loopback exemption parses +the URL rather than substring-matching, so `http://127.0.0.1.evil.com` is +correctly treated as remote. + +**`identities.supertokens_user_id` was overwritten with our own `users.id` on +every login after the first.** Found independently by two reviewers, each +verifying it by running the override twice. Once a mapping exists the core +translates on the way out, so `readSupertokensUserId` returns the *external* +id, which was then written back over the real one. Latent but real: it +destroyed the linkage record exactly when a rollout would need to reverse-map +an ST id to a player, a crash between the two writes self-healed into the +*wrong* value, and once account linking ships two identities on one `users.id` +would both write that id and collide on the UNIQUE constraint — a permanent +login failure, and the hollowing-out of the constraint's documented meaning. + +**The audit could not see orphaned identity rows.** It enumerated +`getAllUsersWithSaves` → `listIdentities`, so a row whose `user_id` points at +no user was unreachable — and that player cannot log in at all. Exactly the +corruption class the gate exists to catch, reported as PASS. Now enumerates +`identities` directly with a `LEFT JOIN users` and reports a distinct `ORPHAN` +outcome that fails the gate. + +**A run of nothing but new players reported PASS.** `passed` gated on the total +result count rather than the *comparable* one, so three `no-identity` results +printed "100% of comparable logins matched" having compared none — the vacuous +pass this module's own comment claims to prevent, and the realistic shape when +an operator shadow-tests with a throwaway account. + +**`POST /auth/signout` survived logout.** The Session recipe registers it +automatically; it revokes the SuperTokens session and leaves the legacy cookie, +so `requireAuth`'s fallback re-authenticated the "logged out" user. The +guarantee in `authRoutes.js` was enforced only on `/auth/logout`, the route the +current client happens to call — and the SuperTokens frontend SDK's `signOut()` +targets `/auth/signout`, so this would have become the default path the moment +the Phase 5 frontend work landed. Removed rather than patched: one logout route +known to be complete beats two that must agree forever. + +**Two simultaneous first logins failed on Postgres.** Three awaited round trips +between the identity read and the insert; both racers insert, and the loser's +`users_pkey` 23505 was misread as a *username* collision, renamed, and retried +into the same key. SQLite was safe by accident (nothing awaits between its read +and insert) — dialect drift, which is precisely what the two-backend rule +exists to surface. Now serialized per player with the existing `withUserLock`, +and the pg driver distinguishes a PK conflict from a username one. + +**Three mutations survived and now fail.** The middleware/`errorHandler` mount +(nothing asserted it was mounted — and the existing +`expect(layerNames).not.toContain('middleware')` could *never* have failed, +since SuperTokens' express bindings are anonymous functions; now asserted by +layer count and by HTTP content-type), logout revoking the SuperTokens session, +and `resolveExternalUserId` reading `identity.user_id` rather than +reconstructing `provider:id` — the divergent row being exactly what the shadow +gate hunts for, so the code that *rescues* such a player was untested while the +code that *detects* them had several tests. + +**Minors fixed:** `GENERAL_ERROR` instead of a thrown `Error` at the bypass +guard (a 500 reads as retryable, and a bare-metal run without +`NODE_ENV=production` would have returned a stack); `jwt.verify` pinned to +HS256; provider ids validated before composition into `users.id`; `exitCode` +instead of `process.exit` so a piped report cannot truncate before the mismatch +list; a clear message when handed a pre-v1.7 export; `PUBLIC_ORIGIN` and +`SUPERTOKENS_API_KEY` added to the Unraid template; the README's +`${DATABASE_URL:-...}` corrected to the non-colon form; and `interface.md`'s +justification for the missing-row no-op rewritten — it claimed a throw would +cause a permanent lockout, which is not what the code does, and would have sent +the next maintainer hunting a broken retry path. + +**Recorded, not fixed:** `mountSuperTokens(app, mode)` is listed under Task 2's +"Produces" but was never built; mounting is inlined in `buildApp`. A reasonable +simplification, noted here because it was the one deviation missing from this +section. + ### Correction to the design Spec §5.5 called the "SuperTokens `thirdPartyUserId` equals passport's diff --git a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md index d1b63fa..339d089 100644 --- a/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md +++ b/docs/superpowers/specs/2026-08-01-postgres-supertokens-design.md @@ -257,9 +257,10 @@ Starts only after v1.7 is confirmed running in production. > > Implementation plan: `docs/superpowers/plans/2026-08-06-v1.8-supertokens.md`. > Operator runbook: `docs/supertokens-rollout-runbook.md`. -> Progress: **all 7 tasks built.** 589 tests green on SQLite, 612 on -> Postgres, 39 e2e smoke assertions, and a real boot verified in each of the -> three `AUTH_MODE` values. Version bumped to 1.8.0; not yet tagged. +> Progress: **all 7 tasks built, and the whole-branch final review run and its +> findings fixed.** 614 tests green on SQLite, 637 on Postgres, 39 e2e smoke +> assertions, and a real boot verified in each of the three `AUTH_MODE` +> values. Version bumped to 1.8.0; not yet tagged. > > **The client side of this design was never in scope and is not built.** Two > gaps, both frontend, neither affecting `dual`: diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index 31a365f..31c93f7 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -261,13 +261,33 @@ docker compose --profile supertokens up -d ``` **Unraid** — add a container from -`registry.supertokens.io/supertokens/supertokens-postgresql`, publish port -3567, and set one variable: +`registry.supertokens.io/supertokens/supertokens-postgresql:9.3` (pin the tag; +this container signs every session, and a silent major upgrade is not a risk +worth taking). Set **two** variables, and **do not publish port 3567**: ``` POSTGRESQL_CONNECTION_URI=postgresql://rackstack:PASSWORD@192.168.x.x:5432/supertokens +API_KEYS= ``` +> **The API key is not optional, and neither is keeping the port private.** +> A SuperTokens core with no `API_KEYS` serves its entire API unauthenticated, +> and that API is the trust root of the whole stack: it will mint a session for +> **any** user id you ask it for. Because the id mapping makes +> `session.getUserId()` return `github:37058311` verbatim, anyone who can reach +> that port can mint a valid RackStack session for any value in +> `SUPER_ADMIN_IDS` — which are deterministic and effectively public — without +> a single request touching RackStack, and therefore without meeting any of its +> guards. +> +> Set the same value as `SUPERTOKENS_API_KEY` on the RackStack container. +> RackStack refuses to start in `dual`/`supertokens` if the core is not on +> loopback and no key is set, so a missed key fails loudly at boot rather than +> quietly at 3am. +> +> Found by the v1.8 final review: the shipped compose file did both of these +> wrong. + Two ways this line goes wrong, both worth reading twice: - **The scheme must be `postgresql://`.** The SuperTokens core rejects @@ -281,15 +301,17 @@ Two ways this line goes wrong, both worth reading twice: ### B3. Check it came up ```bash -curl -s http://127.0.0.1:3567/hello +docker compose exec supertokens bash -c 'curl -s http://127.0.0.1:3567/hello' ``` -Expect `Hello`. If it does not respond, check the core's log for a connection +Expect `Hello`. (Run from inside the container, since the port is deliberately +not published to the host.) If it does not respond, check the core's log for a connection error against the database from B1 — that is the overwhelmingly common cause. ### B4. Point RackStack at it — but do not switch yet -Set `SUPERTOKENS_CONNECTION_URI` on the RackStack container. **Leave +Set `SUPERTOKENS_CONNECTION_URI` **and `SUPERTOKENS_API_KEY`** (the same value +you put in `API_KEYS` on the core) on the RackStack container. **Leave `AUTH_MODE` blank.** The variable is only read in `dual`/`supertokens`, so setting it now is inert and gets the configuration out of the way before the step that actually changes behaviour. @@ -323,10 +345,18 @@ npm run shadow:check It reads whichever database your usual environment variables point at (`DATABASE_URL`, or `DB_PATH` for SQLite) and checks every identity row. -**It is read-only.** It issues nothing but SELECTs, touches no session, and -creates nothing. Safe to run against production with players online — and safe -to run against a restored export on a laptop, which is the intended use, since -this has to clear *before* the SuperTokens stack is switched on. +**It is read-only.** It opens its own connection — SQLite read-only, Postgres +in a `READ ONLY` transaction — issues one SELECT, and creates nothing. Safe to +run against production with players online, and safe against a restored export +on a laptop, which is the intended use since this has to clear *before* the +SuperTokens stack is switched on. + +> **This was not true before v1.8.0-rc.** The audit used to go through the +> normal database module, and merely *loading* that runs the schema migration — +> which on SQLite renames case-colliding usernames and rebuilds the `users` +> table. Pointed at a pre-v1.7 export it quietly upgraded and rewrote it, then +> printed `GATE: PASS`. Found by the v1.8 final review. If you are running an +> older build, do not point `shadow:check` at anything you care about. A clean run: @@ -335,13 +365,14 @@ A clean run: [shadow] MATCH discord:536626725380161537 -> discord:536626725380161537 === SuperTokens shadow-mode report === -logins compared: 2 +identities compared: 2 matched: 2 mismatched: 0 -no existing identity: 0 (new players - not failures) +orphaned: 0 (identity points at a missing user) +no existing identity: 0 (new players - not failures, not compared) match rate: 100.00% -GATE: PASS - 100% of comparable logins matched. Cutover to AUTH_MODE=dual is cleared. +GATE: PASS - 100% of comparable identities matched. Cutover to AUTH_MODE=dual is cleared. ``` Exit code 0 means pass; anything else means do not proceed. @@ -411,6 +442,7 @@ message names the cause; the three common ones: | `Invalid AUTH_MODE` | Typo. Values are exact lowercase. | `passport`, `dual`, `supertokens` | | `requires SUPERTOKENS_CONNECTION_URI` | Part B4 not done | Set it, restart | | `needs to know this server's public origin` | No `PUBLIC_ORIGIN` and no callback URL to derive it from | Set `PUBLIC_ORIGIN` | +| `requires SUPERTOKENS_API_KEY` | The core is not on loopback and no key is set — it would be answering to anyone who can reach it | Set `API_KEYS` on the core and `SUPERTOKENS_API_KEY` here to the same value | A refusal to start is the designed behaviour for a misconfiguration, not a failure of the rollout. Nothing has changed for players at that point — the @@ -495,3 +527,6 @@ gone wrong and will send you chasing the wrong problem. | GitHub login fails with `redirect_uri` mismatch | Part A2 — widen the registered callback to `/auth`. | | Anything looks wrong during rollout | `AUTH_MODE=passport`, restart. Nobody is logged out. | | Everyone got logged out | Check `JWT_SECRET` is unchanged before anything else. | +| Container won't start, wants `SUPERTOKENS_API_KEY` | Correct and deliberate. An unauthenticated core can mint a session for any user id, `SUPER_ADMIN_IDS` included. Set `API_KEYS` on the core and the same value here. | +| `shadow:check` says the database predates the v1.7 split | You restored a pre-v1.7 export. Migrate it to v1.7 first, or point at the right database. | +| `shadow:check` reports `ORPHAN` rows | An identity points at a user that does not exist; that player cannot log in. Investigate before cutting over — do not ignore it. | diff --git a/server/db/interface.md b/server/db/interface.md index 306b65d..7587bd6 100644 --- a/server/db/interface.md +++ b/server/db/interface.md @@ -102,11 +102,17 @@ these breaks every consumer. SuperTokens id — genuine corruption — and is deliberately allowed to throw. A missing identity row is a silent no-op, matching `setRoles` / - `setToursCompleted`. Deliberate rather than lenient: the sole caller runs - immediately after `createUserIdMapping`, so throwing here would fail the - login while leaving the core-side mapping in place, and the retry would - then fail on the already-exists mapping — a permanent lockout, which is - strictly worse than an unrecorded bookkeeping column. + `setToursCompleted`. The caller resolves or creates the identity immediately + beforehand, so a miss cannot happen without a caller-side bug — and failing + a login over an unrecorded bookkeeping column would be a poor trade. + + > An earlier version of this note justified the no-op by claiming a throw + > would strand the core-side mapping and cause a permanent lockout on retry. + > That was wrong, and the v1.8 final review caught it: `linkExternalUserId` + > reads `getUserIdMapping` *first* and takes the already-mapped branch on a + > retry, and separately handles `USER_ID_MAPPING_ALREADY_EXISTS_ERROR`. There + > is no lockout. The no-op is still right; the stated mechanism was fiction, + > and would have sent the next maintainer looking for a broken retry path. ## Schema versioning diff --git a/unraid-template.xml b/unraid-template.xml index 7903001..c2813ff 100644 --- a/unraid-template.xml +++ b/unraid-template.xml @@ -32,9 +32,11 @@ - + + +