Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 18 additions & 9 deletions docs/authentication-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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 |
|---|---|
Expand Down
9 changes: 5 additions & 4 deletions docs/supertokens-rollout-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rackstack-server",
"version": "1.8.0",
"version": "1.8.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
8 changes: 7 additions & 1 deletion server/supertokens/shadow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
41 changes: 40 additions & 1 deletion server/supertokens/shadowCheck.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,52 @@ 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)
: sqliteReader(resolveSqlitePath(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 {
Expand All @@ -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();
}
Expand Down
74 changes: 74 additions & 0 deletions tests/supertokens.shadow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down
Loading