From 48b38c30a66288f48e1ce44677e432c0bee3e19c Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Fri, 7 Aug 2026 23:28:28 -0400 Subject: [PATCH 1/5] Phase 0 is done: the Unraid box is on Postgres The v1.8.1 shadow report names the database it read, and the production run answered the question these docs were still hedging about: [shadow] auditing postgres postgresql://rackstack_user@...:5432/rackstack So v1.7's Postgres cutover has in fact happened. Both the migration plan and the runbook still said it was outstanding or unconfirmed, which is exactly the kind of stale "not verified" claim this project has been bitten by in the other direction. Phase 0 is now fully satisfied - the only work left before AUTH_MODE=dual is widening the OAuth redirect URLs and standing up the SuperTokens core. Docs only; no code touched, so no version bump. Co-Authored-By: Claude Opus 5 --- docs/authentication-methods.md | 14 +++++++------- docs/supertokens-rollout-runbook.md | 5 +++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/authentication-methods.md b/docs/authentication-methods.md index ec30502..1e97ec7 100644 --- a/docs/authentication-methods.md +++ b/docs/authentication-methods.md @@ -88,19 +88,19 @@ that is what bounds how far the rollout can go — see Phase 5. ## Phase 0 — Prerequisites -1. **v1.7 running in production on Postgres.** Status unconfirmed — the Unraid - box may still be on SQLite. From v1.8.1 the shadow report names the database - it read, so `npm run shadow:check` now tells you which. Note the SuperTokens - core needs its own **Postgres** database in Phase 2 regardless of what - RackStack itself uses, so a Postgres instance is required either way. See - [`postgres-migration-runbook.md`](./postgres-migration-runbook.md). +1. ~~v1.7 running in production on Postgres.~~ **DONE.** Confirmed 2026-08-08 + by the v1.8.1 shadow report, which names the database it read: + `postgres postgresql://rackstack_user@…:5432/rackstack`. The SuperTokens + core in Phase 2 needs its **own** database on that same instance — never the + `rackstack` one. 2. ~~A current production export supplied, for the shadow gate.~~ **Moot — satisfied a better way.** The gate was run directly on the Unraid container on 2026-08-08 (`GATE: PASS`, 6/6), which audits the live database rather than a copy of it. No export is needed. 3. **A backup**, taken the same way as for the Postgres migration. -**Gate:** 1 and 3. Phase 3 has already passed. +**Gate:** 3 (a backup). 1 is done and Phase 3 has already passed, so the only +work left before `dual` is Phases 1 and 2. ## Phase 1 — Widen the OAuth redirect URLs diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index 9d824f1..3aefe48 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -54,8 +54,9 @@ worse than one that admits it has not: `GATE: PASS`. **Cutover to `AUTH_MODE=dual` is cleared.** - **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. +- ~~v1.7 has not been cut over on the Unraid box either.~~ **DONE** — + confirmed 2026-08-08 by the v1.8.1 shadow report naming the database it + read (`postgres postgresql://rackstack_user@…:5432/rackstack`). - **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 cannot be used yet** — and the reason is bigger From 26f17f67457a288785a3be49214392f7f40f3477 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Fri, 7 Aug 2026 23:50:18 -0400 Subject: [PATCH 2/5] Add a Phase 2 deployment preflight: npm run supertokens:check Phase 2 stands up the SuperTokens core, and until now nothing verified it was configured correctly - the operator's only feedback was to set AUTH_MODE and watch what broke. One of the failure modes does not break visibly at all: a core running without API_KEYS mints a login session for ANY user id, and because the id mapping makes session.getUserId() return `github:37058311` verbatim, anyone who can reach it can mint a session for any SUPER_ADMIN_IDS value without a request ever touching RackStack. Nothing inside the app would ever surface that. So the preflight checks it directly, along with the four other things that otherwise only surface after cutover: - core reachable (the localhost-from-inside-a-container mistake) - core REQUIRES authentication (the invisible one above) - our SUPERTOKENS_API_KEY is actually accepted - a mismatch fails every login the moment AUTH_MODE is set - the core has its OWN database, detected by looking for SuperTokens tables inside rackstack's - public origin and providers resolve, the two documented boot failures It also prints the exact redirect URLs to register, including the reason GitHub's is the PARENT path: GitHub allows one URL and matches subdirectories, so registering the SuperTokens path directly would break every existing login. Deliberately does not read AUTH_MODE and does not import server/db/index.js - the whole value is running it before anything is switched, and it must be safe on a box still serving the legacy stack. Same read-only discipline as shadow:check, which gates the data half of the same cutover. Loopback is treated the same way init.js treats it: a missing API key is a WARN rather than a FAIL when only this host can reach the core. 648 tests green on SQLite, 674 on Postgres, 39 smoke assertions. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 14 ++ docs/supertokens-rollout-runbook.md | 25 ++- package.json | 1 + server/supertokens/preflight.js | 260 ++++++++++++++++++++++++++++ tests/supertokens.preflight.test.js | 192 ++++++++++++++++++++ 5 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 server/supertokens/preflight.js create mode 100644 tests/supertokens.preflight.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba44dd..fc85ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## Unreleased + +- **`npm run supertokens:check`** — a deployment preflight for the SuperTokens + rollout, to be run *before* `AUTH_MODE` is set. Verifies the core is + reachable, **requires authentication**, accepts your API key, and has its own + database rather than sharing RackStack's; also checks the public origin and + providers, and prints the exact redirect URLs to register. + + The authentication check is the one that earns its keep: a core running + without `API_KEYS` will mint a login session for any user id — including + every value in `SUPER_ADMIN_IDS` — without a request ever reaching RackStack, + so nothing about it fails visibly. `shadow:check` gates the data half of the + cutover; this gates the deployment half. + ## v1.8.1 - **`npm run shadow:check` now names the database it audited**, both as a log diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index 3aefe48..b1c50ba 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -313,7 +313,30 @@ docker compose exec supertokens bash -c 'curl -s http://127.0.0.1:3567/hello' ``` 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 +not published to the host.) + +### B3a. Run the preflight — this is the gate for Part B + +```bash +npm run supertokens:check +``` + +Read-only, and it does not read `AUTH_MODE` — the point is to verify the +deployment *before* you flip anything. It checks the five things that otherwise +only surface after cutover, one of which never surfaces at all: + +| Check | Why it is here | +|---|---| +| Core reachable | The `localhost`-from-inside-a-container mistake | +| **Core requires authentication** | **A core with no `API_KEYS` mints a session for any user id, `SUPER_ADMIN_IDS` included, without a request ever reaching RackStack. Nothing about this fails visibly.** | +| `SUPERTOKENS_API_KEY` accepted | A mismatch fails every login the moment `AUTH_MODE` is set | +| Core has its own database | Detects SuperTokens tables sitting inside the `rackstack` database | +| Providers + public origin | The two boot failures in D3 | + +It also prints the exact redirect URLs to register with each provider. + +**Gate: `PREFLIGHT: PASS` (exit 0).** A `FAIL` names what to change. This gates +the *deployment*; `shadow:check` (Part C) gates the *data*. Both must pass. 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 diff --git a/package.json b/package.json index 07b52d7..32dedd0 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test:all": "npm run test:sqlite && npm test", "migrate:pg": "node server/db/migrate.js", "shadow:check": "node server/supertokens/shadowCheck.js", + "supertokens:check": "node server/supertokens/preflight.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/preflight.js b/server/supertokens/preflight.js new file mode 100644 index 0000000..54e2f9e --- /dev/null +++ b/server/supertokens/preflight.js @@ -0,0 +1,260 @@ +#!/usr/bin/env node +// Phase 2 preflight: `npm run supertokens:check`. +// +// Verifies that a SuperTokens core is configured correctly BEFORE anyone sets +// AUTH_MODE. That ordering is the whole point - every check below is one an +// operator would otherwise only discover by flipping the switch and watching +// what breaks, and one of them (an unauthenticated core) is not something that +// breaks visibly at all. +// +// Deliberately does NOT read AUTH_MODE, and deliberately does not import +// server/db/index.js. It is a read-only diagnostic that must be safe to run at +// any point, including on a box still happily serving the legacy stack. +// +// Companion to `npm run shadow:check`, which gates the DATA half of the +// cutover. This gates the DEPLOYMENT half. + +import { buildProviders, resolvePublicOrigin, PROVIDER_IDS } from './providers.js'; +import { isLoopback } from './init.js'; + +const PASS = 'PASS'; +const FAIL = 'FAIL'; +const WARN = 'WARN'; + +/** A single check result. `fatal` marks a FAIL that must block the cutover. */ +function result(status, name, detail) { + return { status, name, detail }; +} + +/** + * An endpoint that requires an API key when one is configured. + * + * NOT `/hello` - that answers unauthenticated by design as a health check, so + * a 200 there proves nothing about whether the core is locked down. + */ +const AUTHED_ENDPOINT = '/recipe/users/count'; + +async function probe(url, { apiKey, fetchImpl, timeoutMs = 5000 }) { + const headers = { 'api-version': '3.0' }; + if (apiKey) headers['api-key'] = apiKey; + return fetchImpl(url, { method: 'GET', headers, signal: AbortSignal.timeout(timeoutMs) }); +} + +/** + * Runs every deployment check and returns the results. + * + * Injected `fetchImpl` and `pgConnect` so the whole thing is testable without a + * core or a database; both default to the real implementations. + */ +export async function runPreflight({ + env = process.env, + fetchImpl = fetch, + pgConnect = defaultPgConnect, +} = {}) { + const checks = []; + const connectionURI = (env.SUPERTOKENS_CONNECTION_URI || '').replace(/\/$/, ''); + const apiKey = env.SUPERTOKENS_API_KEY; + + // ---- 1. Is there anything to check? ------------------------------------- + if (!connectionURI) { + checks.push(result( + FAIL, 'SUPERTOKENS_CONNECTION_URI', + 'Not set. Point it at the core, e.g. http://192.168.1.10:3567 - and not at ' + + 'localhost from inside a container, where that means the container itself.', + )); + return checks; // Nothing else is meaningful without it. + } + checks.push(result(PASS, 'SUPERTOKENS_CONNECTION_URI', connectionURI)); + + // ---- 2. Public origin ---------------------------------------------------- + const origin = resolvePublicOrigin(env); + checks.push(origin + ? result(PASS, 'public origin', origin) + : result( + FAIL, 'public origin', + 'Cannot be determined. Set PUBLIC_ORIGIN, or configure GITHUB_CALLBACK_URL / ' + + 'DISCORD_CALLBACK_URL as you would for passport. The server refuses to start ' + + 'in dual/supertokens without it.', + )); + + // ---- 3. Providers -------------------------------------------------------- + const providers = buildProviders(env).map((p) => p.config.thirdPartyId); + if (providers.length === 0) { + checks.push(result( + FAIL, 'OAuth providers', + `None configured. Set GITHUB_CLIENT_ID/SECRET and/or DISCORD_CLIENT_ID/SECRET, ` + + `or SuperTokens would start with no way for anyone to log in. Known: ${PROVIDER_IDS.join(', ')}.`, + )); + } else { + checks.push(result(PASS, 'OAuth providers', providers.join(', '))); + if (origin) { + // The redirect URLs the operator must have registered. Printed rather + // than probed - we cannot ask GitHub what is registered - but getting + // them wrong is the single most common way the first login fails, so + // spelling them out beats leaving it to a doc lookup. + for (const id of providers) { + checks.push(result( + PASS, `${id} redirect to register`, + `${origin}/auth/callback/${id}` + + (id === 'github' ? ` (register the PARENT path ${origin}/auth - GitHub allows one URL and matches subdirectories)` : ''), + )); + } + } + } + + // ---- 4. Is the core reachable? ------------------------------------------ + let reachable = false; + try { + const hello = await probe(`${connectionURI}/hello`, { fetchImpl }); + reachable = hello.status === 200; + checks.push(reachable + ? result(PASS, 'core reachable', `${connectionURI}/hello answered 200`) + : result(FAIL, 'core reachable', `${connectionURI}/hello answered ${hello.status}, expected 200`)); + } catch (e) { + checks.push(result( + FAIL, 'core reachable', + `${connectionURI} did not answer (${e.message}). Check the container is running, and that ` + + 'the host is not `localhost` if RackStack runs in a different container.', + )); + } + + if (!reachable) return checks; + + // ---- 5. THE ONE THAT MATTERS: is the core locked down? ------------------- + // + // A core with no API_KEYS serves its whole API unauthenticated, and that API + // mints a session for ANY user id. Because the id mapping makes + // session.getUserId() return `github:37058311` verbatim, anyone who can reach + // it can mint a RackStack session for any SUPER_ADMIN_IDS value without a + // single request touching RackStack. Nothing about this fails visibly, which + // is exactly why it is checked here rather than left to be noticed. + try { + const anon = await probe(`${connectionURI}${AUTHED_ENDPOINT}`, { fetchImpl }); + if (anon.status === 401) { + checks.push(result(PASS, 'core requires authentication', 'anonymous request rejected (401)')); + } else { + checks.push(result( + FAIL, 'core requires authentication', + `An anonymous request got HTTP ${anon.status}. The core is running without API_KEYS: ` + + 'anyone who can reach it can mint a login session for any user id, including every ' + + 'value in SUPER_ADMIN_IDS. Set API_KEYS on the core (openssl rand -hex 32), set the ' + + "same value as SUPERTOKENS_API_KEY here, and do not publish the core's port.", + )); + } + } catch (e) { + checks.push(result(WARN, 'core requires authentication', `could not verify (${e.message})`)); + } + + // ---- 6. Does OUR key actually work? ------------------------------------- + if (!apiKey) { + checks.push(result( + isLoopback(connectionURI) ? WARN : FAIL, 'SUPERTOKENS_API_KEY', + isLoopback(connectionURI) + ? 'Not set. Tolerated because the core is on loopback and only this host can reach it.' + : 'Not set, and the core is not on loopback. The server will refuse to start in ' + + 'dual/supertokens mode until this matches API_KEYS on the core.', + )); + } else { + try { + const authed = await probe(`${connectionURI}${AUTHED_ENDPOINT}`, { apiKey, fetchImpl }); + checks.push(authed.status === 200 + ? result(PASS, 'SUPERTOKENS_API_KEY', 'accepted by the core') + : result( + FAIL, 'SUPERTOKENS_API_KEY', + `The core rejected it (HTTP ${authed.status}). It must be byte-identical to a value in ` + + "the core's API_KEYS. A mismatch here fails every login once AUTH_MODE is set.", + )); + } catch (e) { + checks.push(result(WARN, 'SUPERTOKENS_API_KEY', `could not verify (${e.message})`)); + } + } + + // ---- 7. Does the core have its OWN database? ---------------------------- + // + // SuperTokens manages its own schema. Pointed at the rackstack database it + // would create its tables alongside the game's - which is not immediately + // fatal, but entangles two schemas that have to be backed up, migrated and + // rolled back independently. Detected by looking for SuperTokens' tables + // inside RackStack's own database. + if (env.DATABASE_URL) { + try { + const stray = await pgConnect(env.DATABASE_URL); + checks.push(stray.length === 0 + ? result(PASS, 'core has its own database', "no SuperTokens tables in RackStack's database") + : result( + FAIL, 'core has its own database', + `Found SuperTokens tables inside RackStack's own database (${stray.join(', ')}). The core's ` + + 'POSTGRESQL_CONNECTION_URI must point at a SEPARATE database on the same server - ' + + 'e.g. CREATE DATABASE supertokens OWNER - never at the rackstack one.', + )); + } catch (e) { + checks.push(result(WARN, 'core has its own database', `could not check (${e.message})`)); + } + } + + return checks; +} + +/** Looks for SuperTokens-owned tables inside RackStack's own database. */ +async function defaultPgConnect(url) { + const pg = (await import('pg')).default; + const client = new pg.Client({ connectionString: url }); + await client.connect(); + try { + await client.query('BEGIN TRANSACTION READ ONLY'); + const { rows } = await client.query(` + SELECT table_name FROM information_schema.tables + WHERE table_schema = current_schema() + AND (table_name LIKE 'supertokens%' OR table_name IN + ('all_auth_recipe_users', 'session_info', 'thirdparty_users', 'key_value')) + ORDER BY table_name + `); + await client.query('COMMIT'); + return rows.map((r) => r.table_name); + } finally { + await client.end(); + } +} + +export function formatPreflight(checks) { + const width = Math.max(...checks.map((c) => c.name.length), 0); + const lines = ['=== SuperTokens deployment preflight ===']; + for (const c of checks) { + lines.push(`[${c.status.padEnd(4)}] ${c.name.padEnd(width)} ${c.detail}`); + } + + const failed = checks.filter((c) => c.status === FAIL); + const warned = checks.filter((c) => c.status === WARN); + lines.push(''); + if (failed.length > 0) { + lines.push( + `PREFLIGHT: FAIL - ${failed.length} problem(s). Do NOT set AUTH_MODE yet; ` + + 'each line above says what to change.', + ); + } else if (warned.length > 0) { + lines.push( + `PREFLIGHT: PASS with ${warned.length} warning(s). Nothing blocks the cutover, but read them.`, + ); + } else { + lines.push('PREFLIGHT: PASS - the deployment side is ready for AUTH_MODE=dual.'); + } + lines.push('Run `npm run shadow:check` too: this gates the deployment, that gates the data.'); + return lines.join('\n'); +} + +export function preflightPassed(checks) { + return checks.length > 0 && !checks.some((c) => c.status === FAIL); +} + +async function main() { + const checks = await runPreflight(); + console.log(formatPreflight(checks)); + process.exitCode = preflightPassed(checks) ? 0 : 1; +} + +if (process.argv[1] && process.argv[1].endsWith('preflight.js')) { + main().catch((e) => { + console.error('[preflight] failed to run:', e); + process.exitCode = 2; + }); +} diff --git a/tests/supertokens.preflight.test.js b/tests/supertokens.preflight.test.js new file mode 100644 index 0000000..c88ec98 --- /dev/null +++ b/tests/supertokens.preflight.test.js @@ -0,0 +1,192 @@ +// The Phase 2 preflight gates the DEPLOYMENT half of the cutover, the way +// shadow:check gates the data half. +// +// The check that earns its keep is "core requires authentication". An +// unauthenticated SuperTokens core mints a session for ANY user id, and the id +// mapping turns that into a real RackStack session for any SUPER_ADMIN_IDS +// value without a request ever reaching Express - so it is invisible from +// inside the app, and nothing else in the system would ever surface it. + +import { describe, it, expect } from 'vitest'; + +const { + runPreflight, formatPreflight, preflightPassed, +} = await import('../server/supertokens/preflight.js'); + +const BASE_ENV = { + SUPERTOKENS_CONNECTION_URI: 'http://core.example.com:3567', + SUPERTOKENS_API_KEY: 'a-real-key', + PUBLIC_ORIGIN: 'https://rackstack.example.com', + GITHUB_CLIENT_ID: 'gh', GITHUB_CLIENT_SECRET: 'ghs', + DISCORD_CLIENT_ID: 'dc', DISCORD_CLIENT_SECRET: 'dcs', +}; + +/** A fake core. `open: true` models one running with no API_KEYS. */ +function fakeCore({ open = false, keyAccepted = true, reachable = true } = {}) { + return async (url, { headers } = {}) => { + if (!reachable) throw new Error('ECONNREFUSED'); + if (url.endsWith('/hello')) return { status: 200 }; + const hasKey = Boolean(headers && headers['api-key']); + if (!hasKey) return { status: open ? 200 : 401 }; + return { status: keyAccepted ? 200 : 401 }; + }; +} + +const noStrayTables = async () => []; +const byName = (checks, name) => checks.find((c) => c.name === name); + +describe('the preflight catches an open core', () => { + it('FAILS when an anonymous request is answered', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore({ open: true }), pgConnect: noStrayTables, + }); + const auth = byName(checks, 'core requires authentication'); + expect(auth.status).toBe('FAIL'); + expect(auth.detail).toMatch(/without API_KEYS/); + expect(auth.detail).toMatch(/SUPER_ADMIN_IDS/); + expect(preflightPassed(checks)).toBe(false); + expect(formatPreflight(checks)).toContain('PREFLIGHT: FAIL'); + }); + + it('PASSES when the core rejects anonymous callers', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + expect(byName(checks, 'core requires authentication').status).toBe('PASS'); + expect(preflightPassed(checks)).toBe(true); + expect(formatPreflight(checks)).toContain('PREFLIGHT: PASS'); + }); + + it('does not probe /hello for the auth check', async () => { + // /hello answers unauthenticated by design, so a 200 there proves nothing. + // If the auth check ever moved to it, an open core would read as healthy. + const probed = []; + await runPreflight({ + env: BASE_ENV, + fetchImpl: async (url, opts) => { + probed.push({ url, keyed: Boolean(opts?.headers?.['api-key']) }); + return url.endsWith('/hello') ? { status: 200 } : { status: opts?.headers?.['api-key'] ? 200 : 401 }; + }, + pgConnect: noStrayTables, + }); + const anonProbes = probed.filter((p) => !p.keyed && !p.url.endsWith('/hello')); + expect(anonProbes.length).toBeGreaterThan(0); + expect(anonProbes.every((p) => p.url.includes('/recipe/'))).toBe(true); + }); +}); + +describe('the preflight catches a key mismatch', () => { + it('FAILS when the core rejects our key', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore({ keyAccepted: false }), pgConnect: noStrayTables, + }); + const key = byName(checks, 'SUPERTOKENS_API_KEY'); + expect(key.status).toBe('FAIL'); + expect(key.detail).toMatch(/byte-identical/); + expect(preflightPassed(checks)).toBe(false); + }); + + it('FAILS on a missing key for a remote core, but only WARNs on loopback', async () => { + const { SUPERTOKENS_API_KEY: _drop, ...noKey } = BASE_ENV; + + const remote = await runPreflight({ + env: noKey, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + expect(byName(remote, 'SUPERTOKENS_API_KEY').status).toBe('FAIL'); + + const local = await runPreflight({ + env: { ...noKey, SUPERTOKENS_CONNECTION_URI: 'http://127.0.0.1:3567' }, + fetchImpl: fakeCore(), + pgConnect: noStrayTables, + }); + expect(byName(local, 'SUPERTOKENS_API_KEY').status).toBe('WARN'); + expect(preflightPassed(local)).toBe(true); + }); +}); + +describe('the preflight catches a shared database', () => { + it('FAILS when SuperTokens tables sit in RackStack\'s own database', async () => { + const checks = await runPreflight({ + env: { ...BASE_ENV, DATABASE_URL: 'postgresql://u@h:5432/rackstack' }, + fetchImpl: fakeCore(), + pgConnect: async () => ['all_auth_recipe_users', 'session_info'], + }); + const db = byName(checks, 'core has its own database'); + expect(db.status).toBe('FAIL'); + expect(db.detail).toMatch(/SEPARATE database/); + expect(preflightPassed(checks)).toBe(false); + }); + + it('PASSES when they are separate', async () => { + const checks = await runPreflight({ + env: { ...BASE_ENV, DATABASE_URL: 'postgresql://u@h:5432/rackstack' }, + fetchImpl: fakeCore(), + pgConnect: noStrayTables, + }); + expect(byName(checks, 'core has its own database').status).toBe('PASS'); + }); +}); + +describe('the preflight on an incomplete configuration', () => { + it('stops early, and usefully, with no connection URI', async () => { + const checks = await runPreflight({ env: {}, fetchImpl: fakeCore(), pgConnect: noStrayTables }); + expect(checks).toHaveLength(1); + expect(checks[0].status).toBe('FAIL'); + expect(checks[0].detail).toMatch(/localhost/); + expect(preflightPassed(checks)).toBe(false); + }); + + it('FAILS when the core cannot be reached, and does not pretend to know more', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore({ reachable: false }), pgConnect: noStrayTables, + }); + expect(byName(checks, 'core reachable').status).toBe('FAIL'); + // No auth verdict should be invented for a core we never spoke to. + expect(byName(checks, 'core requires authentication')).toBeUndefined(); + }); + + it('FAILS with no OAuth provider configured', async () => { + const checks = await runPreflight({ + env: { SUPERTOKENS_CONNECTION_URI: BASE_ENV.SUPERTOKENS_CONNECTION_URI, PUBLIC_ORIGIN: BASE_ENV.PUBLIC_ORIGIN }, + fetchImpl: fakeCore(), + pgConnect: noStrayTables, + }); + expect(byName(checks, 'OAuth providers').status).toBe('FAIL'); + }); + + it('FAILS when the public origin cannot be determined', async () => { + const { PUBLIC_ORIGIN: _drop, ...noOrigin } = BASE_ENV; + const checks = await runPreflight({ + env: noOrigin, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + expect(byName(checks, 'public origin').status).toBe('FAIL'); + }); +}); + +describe('the preflight tells the operator what to register', () => { + it('prints the exact redirect URLs, and the GitHub parent-path caveat', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + const gh = byName(checks, 'github redirect to register'); + const dc = byName(checks, 'discord redirect to register'); + + expect(gh.detail).toContain('https://rackstack.example.com/auth/callback/github'); + // GitHub allows one URL and matches subdirectories, so the PARENT path is + // what gets registered - getting this wrong breaks every existing login. + expect(gh.detail).toContain('https://rackstack.example.com/auth'); + expect(gh.detail).toMatch(/subdirector/i); + expect(dc.detail).toContain('https://rackstack.example.com/auth/callback/discord'); + }); + + it('does not claim PASS on an empty run', async () => { + expect(preflightPassed([])).toBe(false); + }); + + it('points at the other half of the gate', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + expect(formatPreflight(checks)).toContain('shadow:check'); + }); +}); From 304019f57cb83ca516027824b32c3fd5f3f40e88 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 00:26:18 -0400 Subject: [PATCH 3/5] Fix a wrong core pin, and add the check that would have caught it The runbook and compose file pinned SuperTokens core 9.3. That version is incompatible: supertokens-node@24 speaks core-driver-interface 5.4 only, and core 9.3 tops out at CDI 5.2. Verified against the core's own coreDriverInterfaceSupported.json - 9.x and 10.x offer up to 5.2, 11.x up to 5.3, and 12.x is the first with 5.4. The failure mode is the nasty kind: that core starts, answers /hello, accepts its API key, and then fails every request on a version mismatch. Nothing about "the container is running" tells you. I picked 9.3 while hardening the compose file away from :latest and never checked it against the SDK. So the preflight now negotiates the version explicitly - it reads the SDK's own declared cdiSupported rather than a hardcoded list, so an SDK upgrade moves the check with it - and both pins move to 12.0. Also switches the image reference from registry.supertokens.io to Docker Hub. Same image, but the SuperTokens registry's certificate chains to ISRG Root YR, a new Let's Encrypt root that older CA bundles do not carry, so pulling from it fails with `x509: certificate signed by unknown authority`. Confirmed the registry itself is healthy (401 on /v2/, chain verifies) - the gap is in the puller's trust store, so this is a workaround for the operator's benefit and the runbook explains how to tell the two apart. Runbook gains both cases in its quick reference, including the openssl command that distinguishes a stale CA bundle from TLS interception. 651 tests green on SQLite, 677 on Postgres. Co-Authored-By: Claude Opus 5 --- docker-compose.yml | 14 +++++++- docs/supertokens-rollout-runbook.md | 23 +++++++++++-- server/supertokens/preflight.js | 34 ++++++++++++++++++++ tests/supertokens.preflight.test.js | 50 ++++++++++++++++++++++++++++- 4 files changed, 116 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1346f10..699f9ef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,19 @@ services: # 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 + # + # Core 12 is the FLOOR, not a preference. supertokens-node@24 speaks + # core-driver-interface 5.4 only, and cores 9.x/10.x top out at CDI 5.2, + # 11.x at 5.3 - all of which run, answer health checks, accept their API + # key, and then fail every single request on a version mismatch. This file + # pinned 9.3 until that was caught; `npm run supertokens:check` now + # verifies the negotiated version so it cannot happen silently again. + # + # Docker Hub rather than registry.supertokens.io: the latter's certificate + # chains to ISRG Root YR, a new Let's Encrypt root that older CA bundles + # (Unraid's included) do not carry yet, giving + # `x509: certificate signed by unknown authority` on pull. Same image. + image: supertokens/supertokens-postgresql:12.0 container_name: rackstack-supertokens profiles: ["supertokens"] restart: unless-stopped diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index b1c50ba..23d11bd 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -269,15 +269,30 @@ docker compose --profile supertokens up -d ``` **Unraid** — add a container from -`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**: +`supertokens/supertokens-postgresql:12.0`. Set **two** variables, and **do not +publish port 3567**: ``` POSTGRESQL_CONNECTION_URI=postgresql://rackstack_user:PASSWORD@192.168.x.x:5432/supertokens API_KEYS= ``` +> **Two things about that image reference.** +> +> **Core 12 is a floor, not a preference.** `supertokens-node@24` speaks +> core-driver-interface 5.4 only. Cores 9.x and 10.x top out at CDI 5.2 and +> 11.x at 5.3 — each of which starts fine, answers its health check, accepts +> its API key, and then fails *every* request on a version mismatch. This +> runbook said `9.3` until that was caught; `npm run supertokens:check` (B3a) +> now verifies the negotiated version. +> +> **Docker Hub, not `registry.supertokens.io`.** Same image, but the +> SuperTokens registry's certificate chains to `ISRG Root YR`, a new Let's +> Encrypt root that older CA bundles — Unraid's included — do not carry yet. +> Pulling from it fails with `x509: certificate signed by unknown authority`. +> That is a trust-store gap on the puller, not an outage. See the quick +> reference if you want to fix the CA bundle instead. + > **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 @@ -569,3 +584,5 @@ gone wrong and will send you chasing the wrong problem. | 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. | +| Pulling the core fails with `x509: certificate signed by unknown authority` | Not an outage. The SuperTokens registry chains to `ISRG Root YR`, a new Let's Encrypt root your CA bundle lacks. Pull `supertokens/supertokens-postgresql:12.0` from Docker Hub instead, or update the host's `ca-certificates`. Confirm which by running `openssl s_client -connect registry.supertokens.io:443 -servername registry.supertokens.io offered.includes(v)); + + if (shared.length > 0) { + checks.push(result(PASS, 'core protocol version', `core and SDK share CDI ${shared.join(', ')}`)); + } else if (offered.length === 0) { + checks.push(result(WARN, 'core protocol version', 'the core did not report its CDI versions')); + } else { + checks.push(result( + FAIL, 'core protocol version', + `The SDK speaks CDI ${cdiSupported.join(', ')} but this core offers up to ` + + `${offered[offered.length - 1]}. The container will run and answer health checks, ` + + 'but every request fails on a version mismatch. Use a newer core image ' + + `(supertokens/supertokens-postgresql:12.0 or later supports ${cdiSupported.join(', ')}).`, + )); + } + } catch (e) { + checks.push(result(WARN, 'core protocol version', `could not verify (${e.message})`)); + } + // ---- 6. Does OUR key actually work? ------------------------------------- if (!apiKey) { checks.push(result( diff --git a/tests/supertokens.preflight.test.js b/tests/supertokens.preflight.test.js index c88ec98..0f16bd8 100644 --- a/tests/supertokens.preflight.test.js +++ b/tests/supertokens.preflight.test.js @@ -22,10 +22,15 @@ const BASE_ENV = { }; /** A fake core. `open: true` models one running with no API_KEYS. */ -function fakeCore({ open = false, keyAccepted = true, reachable = true } = {}) { +function fakeCore({ + open = false, keyAccepted = true, reachable = true, cdi = ['5.3', '5.4', '5.5'], +} = {}) { return async (url, { headers } = {}) => { if (!reachable) throw new Error('ECONNREFUSED'); if (url.endsWith('/hello')) return { status: 200 }; + if (url.endsWith('/apiversion')) { + return { status: 200, json: async () => ({ versions: cdi }) }; + } const hasKey = Boolean(headers && headers['api-key']); if (!hasKey) return { status: open ? 200 : 401 }; return { status: keyAccepted ? 200 : 401 }; @@ -75,6 +80,49 @@ describe('the preflight catches an open core', () => { }); }); +describe('the preflight catches a core too old for the SDK', () => { + it('FAILS on a core that tops out below the SDK\'s CDI', async () => { + // The real case: the runbook originally pinned core 9.3, which offers up + // to CDI 5.2 while supertokens-node@24 requires 5.4. That core runs, + // answers /hello, accepts its API key - and fails every single request. + // Nothing else in this preflight would have caught it. + const checks = await runPreflight({ + env: BASE_ENV, + fetchImpl: fakeCore({ cdi: ['5.0', '5.1', '5.2'] }), + pgConnect: noStrayTables, + }); + const cdi = byName(checks, 'core protocol version'); + expect(cdi.status).toBe('FAIL'); + expect(cdi.detail).toMatch(/5\.2/); + expect(cdi.detail).toMatch(/every request fails/); + expect(preflightPassed(checks)).toBe(false); + }); + + it('PASSES when the core offers a CDI version the SDK speaks', async () => { + const checks = await runPreflight({ + env: BASE_ENV, fetchImpl: fakeCore(), pgConnect: noStrayTables, + }); + const cdi = byName(checks, 'core protocol version'); + expect(cdi.status).toBe('PASS'); + expect(cdi.detail).toMatch(/5\.4/); + }); + + it('checks against the SDK\'s real declared versions, not a hardcoded list', async () => { + // If supertokens-node is upgraded and its CDI requirement moves, this + // check must move with it rather than silently keep asserting 5.4. + const { cdiSupported } = await import('supertokens-node/lib/build/version.js'); + expect(Array.isArray(cdiSupported)).toBe(true); + expect(cdiSupported.length).toBeGreaterThan(0); + + const checks = await runPreflight({ + env: BASE_ENV, + fetchImpl: fakeCore({ cdi: cdiSupported }), + pgConnect: noStrayTables, + }); + expect(byName(checks, 'core protocol version').status).toBe('PASS'); + }); +}); + describe('the preflight catches a key mismatch', () => { it('FAILS when the core rejects our key', async () => { const checks = await runPreflight({ From 24a887831007fcfd53754b01ef189008f0630e69 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 00:29:53 -0400 Subject: [PATCH 4/5] Pin the core's major, and verify the negotiated version at boot Answering "should we just use latest?": no, but not a frozen patch either. `:latest` would cross a MAJOR boundary unannounced, and that is the only place core-driver-interface support realistically changes. supertokens-node@24 speaks CDI 5.4 *only* - one version, not a range - so the compatible window is narrow, and leaving it does not degrade, it breaks every login. Freezing a patch has the opposite problem: a stale core signing every session, missing fixes. So the compose file pins `:12` - major held, minor and patch float. Within a major it is very safe: core 12 still serves CDI 2.7 through 5.5. That still leaves residual risk, so the residual risk is now loud. initSuperTokens verifies the negotiated version at BOOT and refuses to start on a mismatch, naming both versions. The SDK does detect this itself, but only from inside a request - getAPIVersion is called by the request helpers - so without this the container looks healthy, passes its health check, and the first sign of trouble is a player reporting they cannot sign in. Unreachable stays a warning, same as the API-key probe: a core still starting must not turn a boot-ordering hiccup into an outage. Both the boot check and the preflight read the SDK's own declared cdiSupported rather than a hardcoded "5.4", so upgrading supertokens-node moves them with it instead of leaving a stale literal asserting the wrong thing. 654 tests green on SQLite, 680 on Postgres. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 ++++++ docker-compose.yml | 28 +++++++++---- docs/supertokens-rollout-runbook.md | 14 +++++-- server/supertokens/init.js | 61 +++++++++++++++++++++++++++++ server/supertokens/preflight.js | 2 +- tests/supertokens.security.test.js | 39 ++++++++++++++++++ 6 files changed, 144 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc85ddd..6775faf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,17 @@ so nothing about it fails visibly. `shadow:check` gates the data half of the cutover; this gates the deployment half. +- **The SuperTokens core version is now verified at boot.** `supertokens-node` + speaks one core-driver-interface version, and a core outside that window + starts cleanly, passes its health check, and then fails every login — the SDK + only notices from inside a request. RackStack now checks at startup and + refuses to boot with a message naming both versions. + + The bundled compose file pins the core's **major** (`:12`) rather than + `:latest` or a frozen patch: `:latest` would cross a major boundary + unannounced, which is the only place protocol support realistically changes, + while a frozen patch means a stale core signing every session. + ## v1.8.1 - **`npm run shadow:check` now names the database it audited**, both as a log diff --git a/docker-compose.yml b/docker-compose.yml index 699f9ef..420ea23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,18 +37,32 @@ services: # 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. # - # Core 12 is the FLOOR, not a preference. supertokens-node@24 speaks - # core-driver-interface 5.4 only, and cores 9.x/10.x top out at CDI 5.2, - # 11.x at 5.3 - all of which run, answer health checks, accept their API - # key, and then fail every single request on a version mismatch. This file - # pinned 9.3 until that was caught; `npm run supertokens:check` now - # verifies the negotiated version so it cannot happen silently again. + # The MAJOR is pinned; the minor and patch float. That is the deliberate + # middle ground between `:latest` and a fully-frozen tag: + # + # - `:latest` would pick up a new MAJOR unannounced, and the major + # boundary is the only place core-driver-interface support realistically + # changes. supertokens-node@24 speaks CDI 5.4 *only* - a single version, + # not a range - so the compatible window is narrow, and leaving it + # breaks every login rather than degrading. + # - Freezing at a patch would mean sitting on a stale core forever, + # missing security fixes on the component that signs every session. + # + # Within a major it is very safe: core 12 still serves CDI 2.7 through 5.5, + # so minor upgrades do not drop protocol support. + # + # Core 12 is also the FLOOR: cores 9.x/10.x top out at CDI 5.2 and 11.x at + # 5.3 - each of which runs, answers health checks, accepts its API key, and + # then fails every request. This file pinned 9.3 until that was caught. + # initSuperTokens now verifies the negotiated version at BOOT (the SDK only + # notices from inside a request, i.e. on the first login), and + # `npm run supertokens:check` verifies it before cutover. # # Docker Hub rather than registry.supertokens.io: the latter's certificate # chains to ISRG Root YR, a new Let's Encrypt root that older CA bundles # (Unraid's included) do not carry yet, giving # `x509: certificate signed by unknown authority` on pull. Same image. - image: supertokens/supertokens-postgresql:12.0 + image: supertokens/supertokens-postgresql:12 container_name: rackstack-supertokens profiles: ["supertokens"] restart: unless-stopped diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index 23d11bd..ffe5692 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -269,7 +269,7 @@ docker compose --profile supertokens up -d ``` **Unraid** — add a container from -`supertokens/supertokens-postgresql:12.0`. Set **two** variables, and **do not +`supertokens/supertokens-postgresql:12`. Set **two** variables, and **do not publish port 3567**: ``` @@ -279,6 +279,14 @@ API_KEYS= > **Two things about that image reference.** > +> **Pin the major, let the minor float — `:12`, not `:latest` and not +> `:12.0.10`.** `:latest` would cross a major boundary unannounced, and that +> is the only place protocol support realistically changes; freezing a patch +> means sitting on a stale core forever. Within a major it is safe — core 12 +> still serves CDI 2.7 through 5.5. RackStack now verifies the negotiated +> version at boot, so if a core ever does drift out of range the container +> refuses to start and says so, rather than failing logins quietly. +> > **Core 12 is a floor, not a preference.** `supertokens-node@24` speaks > core-driver-interface 5.4 only. Cores 9.x and 10.x top out at CDI 5.2 and > 11.x at 5.3 — each of which starts fine, answers its health check, accepts @@ -584,5 +592,5 @@ gone wrong and will send you chasing the wrong problem. | 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. | -| Pulling the core fails with `x509: certificate signed by unknown authority` | Not an outage. The SuperTokens registry chains to `ISRG Root YR`, a new Let's Encrypt root your CA bundle lacks. Pull `supertokens/supertokens-postgresql:12.0` from Docker Hub instead, or update the host's `ca-certificates`. Confirm which by running `openssl s_client -connect registry.supertokens.io:443 -servername registry.supertokens.io offered.includes(v)); + if (shared.length > 0) return shared; + + throw new Error( + `The SuperTokens core at ${connectionURI} speaks core-driver-interface ` + + `${offered[offered.length - 1]} at newest, but this SDK requires ` + + `${cdiSupported.join(' or ')}. The core would start and answer health checks while ` + + 'failing every login. Use a core image new enough for that interface - ' + + 'supertokens/supertokens-postgresql:12 or later at the time of writing.', + ); +} + export function disableStockSignOut(originalImplementation) { return { ...originalImplementation, signOutPOST: undefined }; } @@ -320,6 +375,12 @@ export async function initSuperTokens({ env = process.env, mode } = {}) { // hiccup into an outage. await assertCoreRejectsAnonymous({ connectionURI, hasKey: Boolean(env.SUPERTOKENS_API_KEY) }); + // Fail fast on a core too old (or too new) for this SDK. Without this the + // mismatch only surfaces on the first login attempt, because the SDK checks + // the version from inside a request - so the container would look healthy + // right up until a player reported they could not sign in. + await assertCoreSpeaksOurProtocol({ connectionURI, apiKey: env.SUPERTOKENS_API_KEY }); + 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 diff --git a/server/supertokens/preflight.js b/server/supertokens/preflight.js index 18e70b3..d3477fc 100644 --- a/server/supertokens/preflight.js +++ b/server/supertokens/preflight.js @@ -172,7 +172,7 @@ export async function runPreflight({ `The SDK speaks CDI ${cdiSupported.join(', ')} but this core offers up to ` + `${offered[offered.length - 1]}. The container will run and answer health checks, ` + 'but every request fails on a version mismatch. Use a newer core image ' - + `(supertokens/supertokens-postgresql:12.0 or later supports ${cdiSupported.join(', ')}).`, + + `(supertokens/supertokens-postgresql:12 or later supports ${cdiSupported.join(', ')}).`, )); } } catch (e) { diff --git a/tests/supertokens.security.test.js b/tests/supertokens.security.test.js index 99abf2a..3a239b2 100644 --- a/tests/supertokens.security.test.js +++ b/tests/supertokens.security.test.js @@ -111,6 +111,45 @@ describe('rejectRawOAuthTokens (authentication bypass guard)', () => { expect(probed).toContain('/recipe/'); }); + it('refuses to boot against a core too old for this SDK', async () => { + // The SDK does catch this, but only from inside a request - so without a + // boot check the container starts healthy, passes its health check, and + // fails every login. This is the difference between "the container will + // not start, and here is why" and a player reporting they cannot sign in. + const { assertCoreSpeaksOurProtocol } = await import('../server/supertokens/init.js'); + const oldCore = async () => ({ status: 200, json: async () => ({ versions: ['5.0', '5.1', '5.2'] }) }); + + await expect(assertCoreSpeaksOurProtocol({ + connectionURI: 'http://core:3567', fetchImpl: oldCore, + })).rejects.toThrow(/core-driver-interface/); + await expect(assertCoreSpeaksOurProtocol({ + connectionURI: 'http://core:3567', fetchImpl: oldCore, + })).rejects.toThrow(/failing every login/); + }); + + it('accepts a core that offers a version the SDK speaks', async () => { + const { assertCoreSpeaksOurProtocol } = await import('../server/supertokens/init.js'); + const { cdiSupported } = await import('supertokens-node/lib/build/version.js'); + // Built from the SDK's own declared support, so upgrading the SDK moves + // this test with it instead of leaving a stale literal behind. + const goodCore = async () => ({ status: 200, json: async () => ({ versions: ['2.7', ...cdiSupported] }) }); + + await expect(assertCoreSpeaksOurProtocol({ + connectionURI: 'http://core:3567', fetchImpl: goodCore, + })).resolves.toEqual(cdiSupported); + }); + + it('warns rather than refuses when the core cannot be reached', async () => { + // Same reasoning as the API-key probe: a core that is still starting must + // not turn a boot-ordering hiccup into an outage. + const { assertCoreSpeaksOurProtocol } = await import('../server/supertokens/init.js'); + const unreachable = async () => { throw new Error('ECONNREFUSED'); }; + + await expect(assertCoreSpeaksOurProtocol({ + connectionURI: 'http://core:3567', fetchImpl: unreachable, + })).resolves.toBe('unverified'); + }); + 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 From 7adc76a253c43b9a69176d3cdb6088608dbe9e14 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 00:34:38 -0400 Subject: [PATCH 5/5] v1.8.2 Ships the Phase 2 preflight and the boot-time core version check, which the rollout needs in hand before the SuperTokens core can be verified. Inert for anyone not rolling SuperTokens out: AUTH_MODE still defaults to passport, and both new checks only run in dual/supertokens mode or when the operator invokes them. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- Dockerfile | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6775faf..06a05fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## v1.8.2 - **`npm run supertokens:check`** — a deployment preflight for the SuperTokens rollout, to be run *before* `AUTH_MODE` is set. Verifies the core is diff --git a/Dockerfile b/Dockerfile index 3656bcb..9143946 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.8.1" +LABEL org.opencontainers.image.version="1.8.2" VOLUME ["/app/data"] EXPOSE 3000 diff --git a/package.json b/package.json index 32dedd0..e0442f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rackstack-server", - "version": "1.8.1", + "version": "1.8.2", "private": true, "type": "module", "scripts": {