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' };