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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Changelog

## 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
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.

- **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
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.1"
LABEL org.opencontainers.image.version="1.8.2"

VOLUME ["/app/data"]
EXPOSE 3000
Expand Down
28 changes: 27 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,33 @@ 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
#
# 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
container_name: rackstack-supertokens
profiles: ["supertokens"]
restart: unless-stopped
Expand Down
14 changes: 7 additions & 7 deletions docs/authentication-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
61 changes: 55 additions & 6 deletions docs/supertokens-rollout-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -268,15 +269,38 @@ 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`. Set **two** variables, and **do not
publish port 3567**:

```
POSTGRESQL_CONNECTION_URI=postgresql://rackstack_user:PASSWORD@192.168.x.x:5432/supertokens
API_KEYS=<openssl rand -hex 32>
```

> **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
> 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
Expand Down Expand Up @@ -312,7 +336,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
Expand Down Expand Up @@ -545,3 +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` 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 </dev/null` on the host — if the issuer is Let's Encrypt, it is your trust store; if it is something else, something is intercepting TLS. |
| `supertokens:check` says the core protocol version is too old | The core image predates CDI 5.4. Use `supertokens/supertokens-postgresql:12` or later. |
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "rackstack-server",
"version": "1.8.1",
"version": "1.8.2",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -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"
},
Expand Down
61 changes: 61 additions & 0 deletions server/supertokens/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,61 @@ export async function assertCoreRejectsAnonymous({ connectionURI, hasKey, fetchI
* loud - and a logout that fails loudly is strictly better than one that half
* works.
*/
/**
* Confirms the core speaks a protocol version this SDK understands.
*
* `supertokens-node` pins an exact set of core-driver-interface versions - at
* the time of writing, exactly one - and the core must offer it. The
* compatible window is therefore narrow, and a core upgrade can leave it.
*
* The SDK does detect this, but only from inside a request: `getAPIVersion` is
* called by the request helpers, so a mismatch surfaces on the first LOGIN
* ATTEMPT, not at startup. The container looks healthy, the health check
* passes, and the first you hear of it is a player saying they cannot log in.
* Checking at boot converts that into a container that refuses to start and
* says why - which is the difference between a five-minute fix and an
* afternoon.
*
* Unreachable is a warning, not a failure, for the same reason as the API-key
* probe: the core may legitimately still be starting.
*/
export async function assertCoreSpeaksOurProtocol({ connectionURI, apiKey, fetchImpl = fetch }) {
const { cdiSupported } = await import('supertokens-node/lib/build/version.js');
const headers = { 'api-version': '3.0' };
if (apiKey) headers['api-key'] = apiKey;

let offered;
try {
const res = await fetchImpl(`${connectionURI.replace(/\/$/, '')}/apiversion`, {
method: 'GET', headers, signal: AbortSignal.timeout(5000),
});
const body = typeof res.json === 'function' ? await res.json() : {};
offered = body?.versions;
} catch (e) {
console.warn(
`[auth] could not verify the SuperTokens core's protocol version (${e.message}). `
+ `This SDK requires core-driver-interface ${cdiSupported.join(' or ')}.`,
);
return 'unverified';
}

if (!Array.isArray(offered) || offered.length === 0) {
console.warn('[auth] the SuperTokens core did not report its core-driver-interface versions.');
return 'unverified';
}

const shared = cdiSupported.filter((v) => 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 };
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading