From e801cecfe630fb5a623304fedefe7f191f55da95 Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Fri, 7 Aug 2026 23:18:05 -0400 Subject: [PATCH] shadow:check names the database it audited The gate's own guidance says a NOT RUN result is usually the wrong database - but the report never said which one it read, so a PASS could not be checked against the box you meant to audit. Printed twice on purpose: once before the run, so a run that dies partway (missing identities table, unreadable media) has still said what it was pointed at, and once inside the report block, which is the part that gets teed to a file and pasted into tickets. The Postgres password is stripped. That output is screenshotted and shared, and a connection string carrying rackstack_user:hunter2 would leak the database password into every copy. Redaction goes through the URL parser rather than a regex, so a password containing an @ cannot survive half-masked - asserted by test. Also brings the docs into line with reality now that the gate has been run against production (2026-08-08, Unraid, 6/6, GATE: PASS): - The runbook's "what has NOT been verified" list had shadow mode as never run. It is now struck through and records the result. Cutover to dual is cleared. - authentication-methods.md Phase 0 dropped the "supply a production export" prerequisite - running the gate on the container audits the live database and is strictly better than auditing a copy. - Phase 0's v1.7-on-Postgres item is now honest that the status is unconfirmed, and notes the SuperTokens core needs its own Postgres database in Phase 2 whatever RackStack itself runs on. - The v1.8.0 changelog entry still says shadow mode had not been run, which was true at that release; the v1.8.1 entry records that it has. 634 tests green on SQLite, 660 on Postgres, 39 smoke assertions. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 19 ++++++++ Dockerfile | 2 +- docs/authentication-methods.md | 27 +++++++---- docs/supertokens-rollout-runbook.md | 9 ++-- package.json | 2 +- server/supertokens/shadow.js | 8 +++- server/supertokens/shadowCheck.js | 41 +++++++++++++++- tests/supertokens.shadow.test.js | 74 +++++++++++++++++++++++++++++ 8 files changed, 165 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3160901..0ba44dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## v1.8.1 + +- **`npm run shadow:check` now names the database it audited**, both as a log + line before the run and inside the report block itself. The gate's own + guidance says a `NOT RUN` result is usually the wrong database — but the + report never said which one it read, so a `PASS` could not be checked against + the box you meant to audit. + + The Postgres password is stripped from the printed connection string. This + output gets `tee`'d to files, screenshotted and pasted into tickets, and + redaction goes through the URL parser rather than a regex so an awkward + password cannot survive half-masked. + +- **The shadow gate has now been run against production** (2026-08-08, on the + Unraid deployment): 6 identities compared, 6 matched, 0 mismatched, 0 + orphaned, 100%, `GATE: PASS`. That settles the one assumption v1.8 could not + verify from source — cutover to `AUTH_MODE=dual` is cleared. The v1.8.0 notes + below, which say it had not been run, were accurate at that release. + ## v1.8.0 SuperTokens as an alternative login stack, behind a switch that is off by diff --git a/Dockerfile b/Dockerfile index 33330f6..3656bcb 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.0" +LABEL org.opencontainers.image.version="1.8.1" VOLUME ["/app/data"] EXPOSE 3000 diff --git a/docs/authentication-methods.md b/docs/authentication-methods.md index ef435fd..ec30502 100644 --- a/docs/authentication-methods.md +++ b/docs/authentication-methods.md @@ -74,7 +74,7 @@ Three consequences worth internalising: | 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, now genuinely read-only — **never run against production** | +| Shadow-mode gate (`npm run shadow:check`) | ✅ built, tested, read-only — **run against production 2026-08-08: `GATE: PASS`, 6/6** | | `oAuthTokens` bypass fix | ✅ built, tested, mutation-verified | | SuperTokens core hardening (API key, port) | ✅ enforced at boot | | Whole-branch security & code review | ✅ run; all findings fixed | @@ -86,17 +86,21 @@ The server side of the rollout is complete and has been through a three-reviewer audit whose findings are fixed. The **client side has not been started**, and that is what bounds how far the rollout can go — see Phase 5. -## Phase 0 — Prerequisites (not yet met) +## Phase 0 — Prerequisites -1. **v1.7 running in production on Postgres.** Still outstanding; the Unraid - box has not been cut over. See +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). -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. +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:** all three, or nothing below happens. +**Gate:** 1 and 3. Phase 3 has already passed. ## Phase 1 — Widen the OAuth redirect URLs @@ -168,7 +172,12 @@ answered by reading library source: - does `user_id` equal `provider:provider_id` for every row actually stored? - does each row's `user_id` point at a user that **exists**? -**Gate: `GATE: PASS` (exit 0).** +**Gate: `GATE: PASS` (exit 0).** The report names the database it audited, so a +PASS can be checked against the box you meant to audit (the Postgres password +is stripped). + +> **Status: passed on 2026-08-08** against the Unraid deployment — 6 identities +> compared, 6 matched, 0 mismatched, 0 orphaned, 100%. Phase 4 is cleared. | Result | Meaning | |---|---| diff --git a/docs/supertokens-rollout-runbook.md b/docs/supertokens-rollout-runbook.md index d206996..9d824f1 100644 --- a/docs/supertokens-rollout-runbook.md +++ b/docs/supertokens-rollout-runbook.md @@ -48,10 +48,10 @@ own code ran. Fixed and tested. 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. +- ~~Shadow mode has never run against production identities.~~ **DONE, + 2026-08-08.** Run on the Unraid container after updating to v1.8.0: 6 + identities compared, 6 matched, 0 mismatched, 0 orphaned, 100%, + `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 @@ -379,6 +379,7 @@ A clean run: [shadow] MATCH discord:536626725380161537 -> discord:536626725380161537 === SuperTokens shadow-mode report === +database: sqlite /app/data/rackstack.db identities compared: 2 matched: 2 mismatched: 0 diff --git a/package.json b/package.json index 0fcd8d1..07b52d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rackstack-server", - "version": "1.8.0", + "version": "1.8.1", "private": true, "type": "module", "scripts": { diff --git a/server/supertokens/shadow.js b/server/supertokens/shadow.js index 95c3048..cee6321 100644 --- a/server/supertokens/shadow.js +++ b/server/supertokens/shadow.js @@ -182,9 +182,15 @@ function pickPair(r) { * inferred from a percentage is a gate people talk themselves past at the end * of a long maintenance window. */ -export function formatSummary(summary) { +export function formatSummary(summary, { source } = {}) { const lines = [ '=== SuperTokens shadow-mode report ===', + // Inside the report block on purpose, not just logged above it: this is + // the part operators tee to a file, screenshot and paste into tickets, and + // a PASS is only meaningful if you can see which database produced it. + // The gate's own NOT-RUN guidance says the usual cause is the wrong + // database - so the report has to name it. + ...(source ? [`database: ${source}`] : []), `identities compared: ${summary.comparable}`, `matched: ${summary.matched}`, `mismatched: ${summary.mismatched}`, diff --git a/server/supertokens/shadowCheck.js b/server/supertokens/shadowCheck.js index ff56893..eeedc4a 100644 --- a/server/supertokens/shadowCheck.js +++ b/server/supertokens/shadowCheck.js @@ -137,6 +137,36 @@ async function sqliteReader(path) { }; } +/** + * A human-readable description of exactly which database is being audited. + * + * The gate's documented failure mode - `GATE: NOT RUN` - is "usually the wrong + * database", and until now the report never said which one it read. An + * operator comparing a PASS against the box they meant to check had nothing to + * compare it to. + * + * The password is stripped. This string is printed to stdout, which operators + * `| tee gate.log`, paste into screenshots, and attach to tickets; a + * connection string carrying `rackstack_user:hunter2` would leak the database + * password into all three. Redacting via the URL parser rather than a regex + * means an odd password (one containing `@`, say) cannot slip through + * half-masked. + */ +export function describeDatabase(env = process.env) { + if (env.DATABASE_URL) { + try { + const url = new URL(env.DATABASE_URL); + if (url.password) url.password = ''; + return `postgres ${url.toString()}`; + } catch { + // Unparseable. Say so rather than echoing it back - it may well be + // unparseable *because* it contains something unexpected. + return 'postgres (DATABASE_URL is set but could not be parsed)'; + } + } + return `sqlite ${resolveSqlitePath(env)}`; +} + export async function openReader(env = process.env) { return env.DATABASE_URL ? pgReader(env.DATABASE_URL) @@ -144,6 +174,15 @@ export async function openReader(env = process.env) { } async function main() { + const source = describeDatabase(); + // Printed before the audit as well as inside the report, so that a run which + // dies partway - a missing identities table, unreadable media - has still + // said which database it was pointed at. That is exactly the run where the + // operator most needs to know. + // The padding in `source` exists to align the report's columns; collapse it + // for the single-line log, where it just reads as a typo. + console.log(`[shadow] auditing ${source.trim().replace(/\s+/g, ' ')}`); + const reader = await openReader(); let summary; try { @@ -153,7 +192,7 @@ async function main() { }); summary = summarise(results); console.log(''); - console.log(formatSummary(summary)); + console.log(formatSummary(summary, { source })); } finally { await reader.close(); } diff --git a/tests/supertokens.shadow.test.js b/tests/supertokens.shadow.test.js index 19c1ed1..48dedf3 100644 --- a/tests/supertokens.shadow.test.js +++ b/tests/supertokens.shadow.test.js @@ -202,6 +202,80 @@ describe('the offline audit (the gate itself)', () => { }); }); +describe('the report names the database it audited', () => { + it('shows the SQLite path', async () => { + const { describeDatabase } = await import('../server/supertokens/shadowCheck.js'); + const described = describeDatabase({ DB_PATH: '/app/data/rackstack.db' }); + expect(described).toContain('sqlite'); + expect(described).toContain('/app/data/rackstack.db'); + }); + + it('shows the Postgres host and database, WITHOUT the password', async () => { + // This string is teed to files, screenshotted and pasted into tickets, so + // echoing the connection string verbatim would leak the database password + // into all three. + const { describeDatabase } = await import('../server/supertokens/shadowCheck.js'); + const described = describeDatabase({ + DATABASE_URL: 'postgresql://rackstack_user:hunter2@192.168.1.10:5432/rackstack', + }); + + expect(described).not.toContain('hunter2'); + expect(described).toContain('rackstack_user'); + expect(described).toContain('192.168.1.10:5432'); + expect(described).toContain('/rackstack'); + }); + + it('does not half-mask a password containing an @', async () => { + // Redaction goes through the URL parser rather than a regex precisely so + // an awkward password cannot survive in part. + const { describeDatabase } = await import('../server/supertokens/shadowCheck.js'); + const described = describeDatabase({ + DATABASE_URL: 'postgresql://u:p%40ss%40word@db.example.com:5432/rackstack', + }); + expect(described).not.toContain('ss@word'); + expect(described).not.toContain('p%40ss'); + expect(described).toContain('db.example.com:5432'); + }); + + it('says so rather than echoing an unparseable DATABASE_URL', async () => { + const { describeDatabase } = await import('../server/supertokens/shadowCheck.js'); + const described = describeDatabase({ DATABASE_URL: 'not a url at all' }); + expect(described).toContain('could not be parsed'); + expect(described).not.toContain('not a url at all'); + }); + + it('prefers DATABASE_URL over DB_PATH, matching openReader', async () => { + // The description must describe what was actually READ. If these two ever + // disagreed, the report would confidently name the wrong database - worse + // than naming none. + const { describeDatabase } = await import('../server/supertokens/shadowCheck.js'); + const described = describeDatabase({ + DATABASE_URL: 'postgresql://u@h:5432/rackstack', + DB_PATH: '/app/data/rackstack.db', + }); + expect(described).toContain('postgres'); + expect(described).not.toContain('/app/data/rackstack.db'); + }); + + it('puts the database inside the report block, not only in the log above it', () => { + const report = formatSummary( + summarise([{ outcome: SHADOW_MATCH, thirdPartyId: 'github', thirdPartyUserId: '1', expectedUserId: 'github:1', actualUserId: 'github:1' }]), + { source: 'sqlite /app/data/rackstack.db' }, + ); + expect(report).toContain('database:'); + expect(report).toContain('/app/data/rackstack.db'); + // A PASS with no database named is the thing this exists to prevent. + expect(report.indexOf('/app/data/rackstack.db')).toBeLessThan(report.indexOf('GATE:')); + }); + + it('omits the line entirely when no source is supplied', () => { + // formatSummary is also called from the live per-login path, which has no + // single database to name. + const report = formatSummary(summarise([])); + expect(report).not.toContain('database:'); + }); +}); + 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' };