v1.8: SuperTokens behind an AUTH_MODE switch (WIP — tasks 1-2 of 7) - #7
Draft
NeverEndingCode wants to merge 4 commits into
Draft
v1.8: SuperTokens behind an AUTH_MODE switch (WIP — tasks 1-2 of 7)#7NeverEndingCode wants to merge 4 commits into
NeverEndingCode wants to merge 4 commits into
Conversation
Seven tasks derived from spec section 5. No v1.8 plan existed - only the design - so this is the missing half. Grounded against the current code rather than the spec's summary of it: confirmed req.user.sub is 25 of the 27 req.user reads (username and avatarUrl one each), which is what makes the "zero route handler changes" seam real; confirmed identities.supertokens_user_id already ships unused from v1.7; and confirmed [email protected] declares no engines constraint, so it is importable on the Node 20 production image. Two v1.7 lessons are carried in as global constraints. The dependency-on-a- newer-Node trap that left CI silently red for four commits is now an explicit check in Task 2 Step 1. The ${VAR:-default} vs ${VAR-default} distinction is spelled out in Task 1 Step 3, because the colon form treats an explicitly empty value as unset and would defeat the documented rollback. Also corrected a claim the spec inherits: it warns that `postgres://` is rejected, which v1.7 disproved for rackstack's own DATABASE_URL (pg accepts both schemes, verified directly). The SuperTokens core is a different component and does reject it, so the plan scopes that warning to the SuperTokens connection URI instead of restating the debunked general claim. Records the sequencing precondition honestly: the spec gates v1.8 on v1.7 being confirmed in production, which has not happened. That blocks running shadow mode against real identities and blocks cutover - but not building, since AUTH_MODE defaults to passport and every task is inert until an operator changes it. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS
Establishes the strangler switch before any SuperTokens code exists, so
every later task lands behind a guard that is already tested rather than
one added around it afterwards.
server/authMode.js resolves AUTH_MODE to one of passport (default), dual or
supertokens. Two decisions worth recording:
Blank means passport, because blanking the field in the Unraid UI is the
documented rollback and must not be an error. But an unrecognised value
THROWS at boot rather than falling back - a typo'd AUTH_MODE=supertoken that
quietly served the legacy stack would be indistinguishable from a completed
rollout, and would surface weeks later from the wrong symptom. Matching is
case-sensitive for the same reason, with the error naming the likely intent
so the fix is in the message.
Documented in all four places an operator can set it: .env.example,
README.md, unraid-template.xml (Display="advanced", so it stays out of the
way of people who will never touch it), and docker-compose.yml. The compose
entry uses ${AUTH_MODE-passport}, deliberately not the colon form - per the
v1.7 finding, ${VAR:-default} treats an explicitly empty value as unset and
would substitute the default right back, defeating the documented rollback.
Also adds docs/supertokens-rollout-runbook.md rather than leaving the
README pointing at a file that does not exist. It is honest about state: a
status table of what is and is not built, and Parts B-D marked pending. Part
A (the OAuth redirect widening) is written in full now because it is pure
documentation, knowable today, and must be applied days BEFORE any cutover -
GitHub requires the redirect path to be a subdirectory of the registered
callback, and /auth/callback/github is not a subdirectory of
/auth/github/callback, so every SuperTokens GitHub login would otherwise
fail with a redirect_uri mismatch. The instruction widens the registration
to /auth, which is additive and reversible; nothing is removed and passport
keeps working throughout.
The runbook also corrects a claim inherited from the design: `postgres://`
is rejected by the SuperTokens core specifically, not by rackstack's own
DATABASE_URL, which v1.7 proved accepts either scheme.
Containment verified: npm run test:all green on both backends with no
AUTH_MODE set - sqlite 507 passed/26 skipped, postgres 530 passed/3 skipped
(+15 each, the new authMode suite). 15 tests cover the default, the empty
and whitespace cases, all three valid values, the throw, the casing
message, and set-level properties: no mode leaves both stacks disabled, and
exactly one mode runs each stack alone.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS
Adds supertokens-node@24 and everything needed to bring SuperTokens up, all
of it dormant in the default passport mode.
CONTAINMENT. Every supertokens-node import is dynamic and inside
initSuperTokens(), after the mode check. This is a hard requirement, not
style: the SDK pulls in nodemailer, twilio and libphonenumber-js to serve
recipes this project never initialises, and v1.7 shipped four commits of
silently-red CI because a top-level import dragged in a package that could
not load on the runtime's Node version. A test asserts the absence of a
static import at the source level, and was confirmed to fail when one is
added. A second test asserts app.js mounts no extra middleware in passport
mode.
buildApp() is now async - not anticipated by the plan. SuperTokens'
middleware() can only be mounted after init(), and init imports dynamically,
so the mount point is necessarily async. All seven call sites updated. It
also takes an { env } override so a test can build the app in another mode
without mutating process.env and leaking that into sibling suites - the
ambient-environment trap that cost v1.7 a whole test run's validity.
Discord's scope is pinned to 'identify' to match passport-discord.
SuperTokens' built-in provider would also request 'email'; asking existing
players to consent to a new scope mid-rollout is indistinguishable from a
phishing prompt and would damage trust in the migration.
The compose service is opt-in via profiles, not a depends_on. In the default
mode RackStack never contacts the core, and blocking every deployment on a
container it will not use turns an unrelated SuperTokens problem into a
RackStack outage.
FINDING, recorded in the spec: the assumption that SuperTokens'
thirdPartyUserId equals passport's profile.id - which section 5.5 called
"load-bearing and unverified" - is now verified at source level for both
providers. supertokens-node's GitHub provider sets thirdPartyUserId =
`${user.id}` and passport-github2 sets profile.id = String(json.id); for
Discord, supertokens maps userInfoMap userId to 'id' and passport-discord
passes Discord's raw JSON through. Same field, same stringification, both
providers. This raises confidence but does NOT retire the shadow gate: what
matters is the values already in the owner's identities rows, which may
predate these library versions.
nodemailer advisory (2 high, via supertokens-node) assessed and accepted,
not fixed. No patched 8.x exists and npm's suggested remediation is
downgrading supertokens-node from 24 to 9.2.3. The vulnerable surface is
message-level `raw`, reachable only when sending email, and nodemailer is
referenced only under the emailpassword/emailverification/passwordless/
webauthn SMTP delivery services - verified by grepping the installed
package. This release initialises ThirdParty and Session only, so none is
ever constructed. Revisit if an email-bearing recipe is ever added.
Node 20 engine check passed: supertokens-node introduces no package
declaring engines.node > 20.
DOCS, updated in the same commit per the owner's instruction. The runbook
gains a full "How your existing Discord and GitHub logins carry over"
section - that no save is rewritten and no id renumbered, what a player
actually experiences in each case (nothing, in every case), the one
assumption it rests on and why the shadow gate still exists, and that
Discord and GitHub remain separate accounts as they always have. Part B
(standing up the core) is now written in full. The plan gains a findings
section recording every deviation above; the spec gains the verification
note, a scoping correction so the postgres:// warning cannot creep back into
applying to DATABASE_URL, and a status block.
Verified: test:all green on both backends - sqlite 526 passed/26 skipped,
postgres 549 passed/3 skipped (+19 each). All six e2e smoke suites pass, 39
assertions, zero errors. Real boots in both modes: passport serves
/auth/authorisationurl as SPA HTML (nothing handles it), dual answers with
SuperTokens JSON - proving the conditional mount works in both directions.
Not verified: a live SuperTokens core, whose image pull hit a Docker Hub
rate limit here.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS
Gap found while reviewing the previous commit's own claim. Task 2 asserted the Node 20 engine check passed, but no test ever imported the SDK: the containment tests run in passport mode, and every configuration-error test throws at init.js's validation - all of which sits BEFORE the dynamic import. The suite would therefore have been fully green on a runtime where supertokens-node could not be loaded at all. That is the exact shape of the v1.7 failure, where four commits of CI were silently red because the broken import lived on a path no green test exercised. An engines field is a claim by the package; loading it is the check. Adds two tests that import supertokens-node, both recipes, and the express framework bindings app.js mounts, asserting each exposes what is used. CI runs Node 20 to match the production image, so this is what makes CI meaningful for this dependency rather than merely passing. Does not weaken the containment assertions: those are source-level (init.js must contain no static import of the SDK), not module-registry-level, and vitest isolates by file. 21 tests in the file, green. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Status: in progress — safe to deploy, not yet ready to roll out
v1.8 introduces SuperTokens as an alternative login stack behind a strangler switch. Tasks 1–2 of 7 are complete. The switch exists, SuperTokens can be brought up behind it, and the default is still the legacy stack — so this branch is safe to deploy today and changes nothing until an operator opts in.
AUTH_MODEswitch + containmentsignInUpoverride)Documents
docs/superpowers/plans/2026-08-06-v1.8-supertokens.md— new; no v1.8 plan existed, only the designdocs/superpowers/specs/2026-08-01-postgres-supertokens-design.md§5docs/supertokens-rollout-runbook.md— new, honest about what is and isn't builtSequencing precondition, stated plainly
The design gates v1.8 on v1.7 being confirmed in production. v1.7 is tagged and published, but the Unraid cutover to Postgres has not happened, and the production export §5.5 depends on has not been supplied.
That gates two specific things — running shadow mode against real identities, and cutting over to
AUTH_MODE=dual. It does not gate building, becauseAUTH_MODEdefaults topassportand every task is inert until an operator changes it. Nothing in this release claims to be verified against production data.What Task 1 ships
server/authMode.jsresolvesAUTH_MODEtopassport(default),dual, orsupertokens. Two decisions worth reviewing:Blank means
passport, but a typo throws. Blanking the field in the Unraid UI is the documented rollback, so it must not error. But an unrecognised value stops the container at boot rather than falling back —AUTH_MODE=supertokenthat quietly served the legacy stack would be indistinguishable from a finished rollout, and would surface weeks later from the wrong symptom. Matching is case-sensitive for the same reason, with the error naming the likely intent.${AUTH_MODE-passport}in compose, deliberately not the colon form. Per the v1.7 finding,${VAR:-default}treats an explicitly empty value as unset and substitutes the default right back — which would defeat the documented rollback.Documented in all four places an operator can set it:
.env.example,README.md,unraid-template.xml(Display="advanced"),docker-compose.yml.The OAuth change, written early on purpose
Part A of the runbook is complete even though it belongs to Task 6, because it is pure documentation, fully knowable now, and must be applied before any cutover — days early is fine.
SuperTokens uses
/auth/callback/<provider>; RackStack uses/auth/<provider>/callback. GitHub requires a redirect path to be a subdirectory of the registered callback URL, and/auth/callback/githubis not a subdirectory of/auth/github/callback. Left alone, every SuperTokens GitHub login fails with aredirect_urimismatch while passport logins keep working — which reads as "SuperTokens is broken" rather than "the OAuth app needs one field widened."The fix widens the GitHub registration to
/auth, so both paths qualify. It is additive and reversible; nothing is removed and passport works throughout.A design claim corrected
The design warns that
postgres://is rejected. v1.7 disproved that for RackStack's ownDATABASE_URL—pg-connection-stringparses both schemes identically, verified directly. The SuperTokens core is a different component and genuinely does reject it, so the plan and runbook scope the warning there instead of restating the debunked general claim.How existing Discord and GitHub logins carry over
Documented in full in the runbook, since it's the part that decides whether a returning player finds their save or a blank one. In short: nothing about a player's account changes.
users.idstays the literalprovider:providerIdstring; SuperTokens issues its own internal id and RackStack maps it onto the existing one, sosession.getUserId()returnsgithub:37058311exactly as the old JWT did. No save is rewritten, no id renumbered, no foreign key moves. Nobody is asked to re-link or re-authorise, and there is no "migrate your account" screen because there is nothing for a player to do.Discord and GitHub remain separate accounts with separate saves, as they always have. Account linking stays out of scope (design §7).
A design assumption, now verified
Spec §5.5 called "SuperTokens'
thirdPartyUserIdequals passport'sprofile.id" load-bearing and unverified. It is now verified at source level against the pinned versions:thirdPartyUserId = `${user.id}`;passport-github2setsprofile.id = String(json.id). Same field, same stringification.userInfoMap.fromUserInfoAPI.userIdtoid;passport-discordpasses Discord's raw JSON through, soprofile.idis that sameid.This raises confidence and does not retire the shadow gate — what matters is the values already in your
identitiesrows, which may predate these library versions. Recorded in the spec.npm audit: 2 high, assessed and acceptedBoth trace to
nodemailerviasupertokens-node. No patched 8.x exists, and npm's suggested remediation is downgradingsupertokens-nodefrom 24 to 9.2.3 — a major downgrade, not a fix.The vulnerable surface is the message-level
rawoption, reachable only when sending email.nodemaileris referenced only underrecipe/{emailverification,emailpassword,passwordless,webauthn}/emaildelivery/services/smtp— verified by grepping the installed package. This release initialises ThirdParty and Session only, so none of those services is ever constructed and no email is ever sent. Unreachable in this configuration; revisit if an email-bearing recipe is ever added.Verification
npm run test:allgreen on both backends:All six e2e smoke suites pass (39 assertions, zero errors).
Real boots in both modes, which is what proves the conditional mount rather than assuming it:
GET /auth/authorisationurl?thirdPartyId=githubpassport200 text/html— the SPA fallback; nothing handles/auth/*dual400 application/json— SuperTokens' middleware answeringA gap I found in my own work: the first Task 2 commit claimed the Node 20 engine check passed, but no test ever imported the SDK — the containment tests run in passport mode and every config-error test throws before the dynamic import. The suite would have been green on a runtime where
supertokens-nodecouldn't load at all, which is exactly how v1.7 shipped four commits of silently-red CI. Closed with tests that actually import the SDK, both recipes, and the express bindings; CI runs Node 20, so that check now means something.Not verified: a live SuperTokens core — the image pull hit a Docker Hub rate limit on this machine.
🤖 Generated with Claude Code
https://claude.ai/code/session_011JYNx69xztWN2qDLe5m1aS