Skip to content

Commit e801cec

Browse files
Evan Phyillaierclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 9849de3 commit e801cec

8 files changed

Lines changed: 165 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## v1.8.1
4+
5+
- **`npm run shadow:check` now names the database it audited**, both as a log
6+
line before the run and inside the report block itself. The gate's own
7+
guidance says a `NOT RUN` result is usually the wrong database — but the
8+
report never said which one it read, so a `PASS` could not be checked against
9+
the box you meant to audit.
10+
11+
The Postgres password is stripped from the printed connection string. This
12+
output gets `tee`'d to files, screenshotted and pasted into tickets, and
13+
redaction goes through the URL parser rather than a regex so an awkward
14+
password cannot survive half-masked.
15+
16+
- **The shadow gate has now been run against production** (2026-08-08, on the
17+
Unraid deployment): 6 identities compared, 6 matched, 0 mismatched, 0
18+
orphaned, 100%, `GATE: PASS`. That settles the one assumption v1.8 could not
19+
verify from source — cutover to `AUTH_MODE=dual` is cleared. The v1.8.0 notes
20+
below, which say it had not been run, were accurate at that release.
21+
322
## v1.8.0
423

524
SuperTokens as an alternative login stack, behind a switch that is off by

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ LABEL org.opencontainers.image.licenses="MIT"
4444
# only on a pushed vX.Y.Z tag, and docker/metadata-action derives the
4545
# published image's version label from that tag - so this literal only
4646
# affects locally-built images, not what GHCR publishes.
47-
LABEL org.opencontainers.image.version="1.8.0"
47+
LABEL org.opencontainers.image.version="1.8.1"
4848

4949
VOLUME ["/app/data"]
5050
EXPOSE 3000

docs/authentication-methods.md

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Three consequences worth internalising:
7474
| SuperTokens init, provider config, mounting | ✅ built, tested |
7575
| Identity mapping (`signInUp` override) | ✅ built, tested, mutation-verified |
7676
| Auth chain (SuperTokens → JWT → 401) | ✅ built, tested in all three modes |
77-
| Shadow-mode gate (`npm run shadow:check`) | ✅ built, tested, now genuinely read-only — **never run against production** |
77+
| Shadow-mode gate (`npm run shadow:check`) | ✅ built, tested, read-only — **run against production 2026-08-08: `GATE: PASS`, 6/6** |
7878
| `oAuthTokens` bypass fix | ✅ built, tested, mutation-verified |
7979
| SuperTokens core hardening (API key, port) | ✅ enforced at boot |
8080
| 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
8686
audit whose findings are fixed. The **client side has not been started**, and
8787
that is what bounds how far the rollout can go — see Phase 5.
8888

89-
## Phase 0 — Prerequisites (not yet met)
89+
## Phase 0 — Prerequisites
9090

91-
1. **v1.7 running in production on Postgres.** Still outstanding; the Unraid
92-
box has not been cut over. See
91+
1. **v1.7 running in production on Postgres.** Status unconfirmed — the Unraid
92+
box may still be on SQLite. From v1.8.1 the shadow report names the database
93+
it read, so `npm run shadow:check` now tells you which. Note the SuperTokens
94+
core needs its own **Postgres** database in Phase 2 regardless of what
95+
RackStack itself uses, so a Postgres instance is required either way. See
9396
[`postgres-migration-runbook.md`](./postgres-migration-runbook.md).
94-
2. **A current production export supplied**, for the shadow gate. The copy in
95-
`~/Downloads` is a stale July v1.1-era file (users + saves only, 4 rows
96-
each) and is not usable for this.
97+
2. ~~A current production export supplied, for the shadow gate.~~ **Moot —
98+
satisfied a better way.** The gate was run directly on the Unraid container
99+
on 2026-08-08 (`GATE: PASS`, 6/6), which audits the live database rather
100+
than a copy of it. No export is needed.
97101
3. **A backup**, taken the same way as for the Postgres migration.
98102

99-
**Gate:** all three, or nothing below happens.
103+
**Gate:** 1 and 3. Phase 3 has already passed.
100104

101105
## Phase 1 — Widen the OAuth redirect URLs
102106

@@ -168,7 +172,12 @@ answered by reading library source:
168172
- does `user_id` equal `provider:provider_id` for every row actually stored?
169173
- does each row's `user_id` point at a user that **exists**?
170174

171-
**Gate: `GATE: PASS` (exit 0).**
175+
**Gate: `GATE: PASS` (exit 0).** The report names the database it audited, so a
176+
PASS can be checked against the box you meant to audit (the Postgres password
177+
is stripped).
178+
179+
> **Status: passed on 2026-08-08** against the Unraid deployment — 6 identities
180+
> compared, 6 matched, 0 mismatched, 0 orphaned, 100%. Phase 4 is cleared.
172181
173182
| Result | Meaning |
174183
|---|---|

docs/supertokens-rollout-runbook.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@ own code ran. Fixed and tested.
4848
Stated plainly, because a runbook that reads as though it has been rehearsed is
4949
worse than one that admits it has not:
5050

51-
- **Shadow mode has never run against production identities.** The owner's
52-
current Unraid export has not been supplied. Part C is tested — including
53-
against a database deliberately seeded with a bad row — but only ever
54-
against test data.
51+
- ~~Shadow mode has never run against production identities.~~ **DONE,
52+
2026-08-08.** Run on the Unraid container after updating to v1.8.0: 6
53+
identities compared, 6 matched, 0 mismatched, 0 orphaned, 100%,
54+
`GATE: PASS`. **Cutover to `AUTH_MODE=dual` is cleared.**
5555
- **No cutover has happened.** `AUTH_MODE` has never been anything but
5656
`passport` on any real deployment.
5757
- **v1.7 has not been cut over on the Unraid box either.** The design gates
@@ -379,6 +379,7 @@ A clean run:
379379
[shadow] MATCH discord:536626725380161537 -> discord:536626725380161537
380380
381381
=== SuperTokens shadow-mode report ===
382+
database: sqlite /app/data/rackstack.db
382383
identities compared: 2
383384
matched: 2
384385
mismatched: 0

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rackstack-server",
3-
"version": "1.8.0",
3+
"version": "1.8.1",
44
"private": true,
55
"type": "module",
66
"scripts": {

server/supertokens/shadow.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,15 @@ function pickPair(r) {
182182
* inferred from a percentage is a gate people talk themselves past at the end
183183
* of a long maintenance window.
184184
*/
185-
export function formatSummary(summary) {
185+
export function formatSummary(summary, { source } = {}) {
186186
const lines = [
187187
'=== SuperTokens shadow-mode report ===',
188+
// Inside the report block on purpose, not just logged above it: this is
189+
// the part operators tee to a file, screenshot and paste into tickets, and
190+
// a PASS is only meaningful if you can see which database produced it.
191+
// The gate's own NOT-RUN guidance says the usual cause is the wrong
192+
// database - so the report has to name it.
193+
...(source ? [`database: ${source}`] : []),
188194
`identities compared: ${summary.comparable}`,
189195
`matched: ${summary.matched}`,
190196
`mismatched: ${summary.mismatched}`,

server/supertokens/shadowCheck.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,52 @@ async function sqliteReader(path) {
137137
};
138138
}
139139

140+
/**
141+
* A human-readable description of exactly which database is being audited.
142+
*
143+
* The gate's documented failure mode - `GATE: NOT RUN` - is "usually the wrong
144+
* database", and until now the report never said which one it read. An
145+
* operator comparing a PASS against the box they meant to check had nothing to
146+
* compare it to.
147+
*
148+
* The password is stripped. This string is printed to stdout, which operators
149+
* `| tee gate.log`, paste into screenshots, and attach to tickets; a
150+
* connection string carrying `rackstack_user:hunter2` would leak the database
151+
* password into all three. Redacting via the URL parser rather than a regex
152+
* means an odd password (one containing `@`, say) cannot slip through
153+
* half-masked.
154+
*/
155+
export function describeDatabase(env = process.env) {
156+
if (env.DATABASE_URL) {
157+
try {
158+
const url = new URL(env.DATABASE_URL);
159+
if (url.password) url.password = '';
160+
return `postgres ${url.toString()}`;
161+
} catch {
162+
// Unparseable. Say so rather than echoing it back - it may well be
163+
// unparseable *because* it contains something unexpected.
164+
return 'postgres (DATABASE_URL is set but could not be parsed)';
165+
}
166+
}
167+
return `sqlite ${resolveSqlitePath(env)}`;
168+
}
169+
140170
export async function openReader(env = process.env) {
141171
return env.DATABASE_URL
142172
? pgReader(env.DATABASE_URL)
143173
: sqliteReader(resolveSqlitePath(env));
144174
}
145175

146176
async function main() {
177+
const source = describeDatabase();
178+
// Printed before the audit as well as inside the report, so that a run which
179+
// dies partway - a missing identities table, unreadable media - has still
180+
// said which database it was pointed at. That is exactly the run where the
181+
// operator most needs to know.
182+
// The padding in `source` exists to align the report's columns; collapse it
183+
// for the single-line log, where it just reads as a typo.
184+
console.log(`[shadow] auditing ${source.trim().replace(/\s+/g, ' ')}`);
185+
147186
const reader = await openReader();
148187
let summary;
149188
try {
@@ -153,7 +192,7 @@ async function main() {
153192
});
154193
summary = summarise(results);
155194
console.log('');
156-
console.log(formatSummary(summary));
195+
console.log(formatSummary(summary, { source }));
157196
} finally {
158197
await reader.close();
159198
}

tests/supertokens.shadow.test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,80 @@ describe('the offline audit (the gate itself)', () => {
202202
});
203203
});
204204

205+
describe('the report names the database it audited', () => {
206+
it('shows the SQLite path', async () => {
207+
const { describeDatabase } = await import('../server/supertokens/shadowCheck.js');
208+
const described = describeDatabase({ DB_PATH: '/app/data/rackstack.db' });
209+
expect(described).toContain('sqlite');
210+
expect(described).toContain('/app/data/rackstack.db');
211+
});
212+
213+
it('shows the Postgres host and database, WITHOUT the password', async () => {
214+
// This string is teed to files, screenshotted and pasted into tickets, so
215+
// echoing the connection string verbatim would leak the database password
216+
// into all three.
217+
const { describeDatabase } = await import('../server/supertokens/shadowCheck.js');
218+
const described = describeDatabase({
219+
DATABASE_URL: 'postgresql://rackstack_user:hunter2@192.168.1.10:5432/rackstack',
220+
});
221+
222+
expect(described).not.toContain('hunter2');
223+
expect(described).toContain('rackstack_user');
224+
expect(described).toContain('192.168.1.10:5432');
225+
expect(described).toContain('/rackstack');
226+
});
227+
228+
it('does not half-mask a password containing an @', async () => {
229+
// Redaction goes through the URL parser rather than a regex precisely so
230+
// an awkward password cannot survive in part.
231+
const { describeDatabase } = await import('../server/supertokens/shadowCheck.js');
232+
const described = describeDatabase({
233+
DATABASE_URL: 'postgresql://u:p%40ss%40word@db.example.com:5432/rackstack',
234+
});
235+
expect(described).not.toContain('ss@word');
236+
expect(described).not.toContain('p%40ss');
237+
expect(described).toContain('db.example.com:5432');
238+
});
239+
240+
it('says so rather than echoing an unparseable DATABASE_URL', async () => {
241+
const { describeDatabase } = await import('../server/supertokens/shadowCheck.js');
242+
const described = describeDatabase({ DATABASE_URL: 'not a url at all' });
243+
expect(described).toContain('could not be parsed');
244+
expect(described).not.toContain('not a url at all');
245+
});
246+
247+
it('prefers DATABASE_URL over DB_PATH, matching openReader', async () => {
248+
// The description must describe what was actually READ. If these two ever
249+
// disagreed, the report would confidently name the wrong database - worse
250+
// than naming none.
251+
const { describeDatabase } = await import('../server/supertokens/shadowCheck.js');
252+
const described = describeDatabase({
253+
DATABASE_URL: 'postgresql://u@h:5432/rackstack',
254+
DB_PATH: '/app/data/rackstack.db',
255+
});
256+
expect(described).toContain('postgres');
257+
expect(described).not.toContain('/app/data/rackstack.db');
258+
});
259+
260+
it('puts the database inside the report block, not only in the log above it', () => {
261+
const report = formatSummary(
262+
summarise([{ outcome: SHADOW_MATCH, thirdPartyId: 'github', thirdPartyUserId: '1', expectedUserId: 'github:1', actualUserId: 'github:1' }]),
263+
{ source: 'sqlite /app/data/rackstack.db' },
264+
);
265+
expect(report).toContain('database:');
266+
expect(report).toContain('/app/data/rackstack.db');
267+
// A PASS with no database named is the thing this exists to prevent.
268+
expect(report.indexOf('/app/data/rackstack.db')).toBeLessThan(report.indexOf('GATE:'));
269+
});
270+
271+
it('omits the line entirely when no source is supplied', () => {
272+
// formatSummary is also called from the live per-login path, which has no
273+
// single database to name.
274+
const report = formatSummary(summarise([]));
275+
expect(report).not.toContain('database:');
276+
});
277+
});
278+
205279
describe('the gate arithmetic', () => {
206280
const match = { outcome: SHADOW_MATCH, thirdPartyId: 'github', thirdPartyUserId: '1', expectedUserId: 'github:1', actualUserId: 'github:1' };
207281
const mismatch = { outcome: SHADOW_MISMATCH, thirdPartyId: 'github', thirdPartyUserId: '2', expectedUserId: 'github:2', actualUserId: 'github:other' };

0 commit comments

Comments
 (0)