Skip to content

v1.9.0: SuperTokens client login + refresh (and the bug that made every SuperTokens login impossible) - #13

Merged
NeverEndingCode merged 3 commits into
mainfrom
worktree-v1.9-supertokens-client
Aug 8, 2026
Merged

v1.9.0: SuperTokens client login + refresh (and the bug that made every SuperTokens login impossible)#13
NeverEndingCode merged 3 commits into
mainfrom
worktree-v1.9-supertokens-client

Conversation

@NeverEndingCode

@NeverEndingCode NeverEndingCode commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Phase 5 of the SuperTokens migration: the client can now log in through SuperTokens and refresh its own session. Plan: docs/superpowers/plans/2026-08-08-v1.9-supertokens-client.md.

The headline is a bug, not a feature

Task 1 of the plan was "drive the login by hand against production dual, no code". It failed immediately, for both providers:

GET /auth/authorisationurl?thirdPartyId=github
400 {"message":"the provider github could not be found in the configuration"}

server/supertokens/init.js passed the OAuth providers to ThirdParty.init as signInUpFeature. The SDK reads signInAndUpFeature. That key is optional in its TypeInput and JavaScript does not reject unknown properties, so the provider list was discarded silently — no throw, no warning, no log. No SuperTokens login was possible on any v1.8 build.

Verified causally against a real SuperTokens core 12, same request, same core, both ways:

init.js says result
signInUpFeature (v1.8) 400 {"message":"the provider github could not be found in the configuration"} — byte-for-byte production
signInAndUpFeature (v1.9) 200 {"status":"OK","urlWithQueryParams":"https://github.com/login/oauth/authorize?..."}

Why a whole release hid it:

  • The boot log printed providers=github,discord in both runs — it logs what buildProviders() returned, not what the recipe received.
  • v1.8 tested both halves and never the seam: that buildProviders() returns the right list, and that init refuses to start when the list is empty. Nothing asserted the list arrives.
  • buildProviders' own "no OAuth provider configured" guard structurally cannot catch it — the list is non-empty, it just never lands.
  • dual never exercised it, because the client logged in via passport. A quiet dual deployment was not evidence the path worked; it could not have been.

Same shape as the superTokensUserId capital-T bug from v1.8. The new test runs the config init.js actually passes through the SDK's own normaliser, so it fails on a wrong key and on a future SDK rename, rather than restating the fix.

The client work

  • client/src/game/auth.js — the three-call flow, hand-rolled rather than supertokens-web-js, whose signOut() targets the /auth/signout v1.8 removed for clearing only half of a dual-stack session.
  • GET /api/auth-info (public) — tells the client which stack to drive. It could not go on /api/config as the plan proposed: that route is behind requireAuth and the caller is by definition not logged in yet. One build therefore serves passport and supertokens alike, so the documented rollback keeps working.
  • Session refresh — one shared in-flight promise. SuperTokens rotates the refresh token on use, so a refresh-per-401 would have calls 2..N present an already-spent token; the core reads that as token theft and revokes the session. A routine renewal becomes a forced logout, and only under concurrency.
  • Cancelled logins, unconfigured providers and refused sign-ins each land back on the login screen with a readable message.

Testing

696 vitest (SQLite) / 722 (Postgres) / 49 smoke — up from 662 / 688 / 39.

Two tests were confirmed by mutation rather than inspection:

  • Deleting the single-flight guard makes the concurrency test report 3 refreshes instead of 1. Its first version passed with the guard deleted — the stub keyed "already expired" off the URL, so only one of the three requests ever got a 401. The test now carries a comment recording that.
  • Disabling the client's callback leg fails two smoke checks.

tests/e2e/smoke-v19.mjs drives the whole redirect round trip over the built client by pointing the stubbed authorisation URL back at the app's own callback, so no external service and no core are needed.

What this does NOT prove

POST /auth/signinup and the identity mapping have still never run against a real core — that needs a real OAuth round trip, so every automated test stubs it. Since the mapping decides whether a returning player lands on their own save or an empty one, do not skip to AUTH_MODE=supertokens.

Plan Task 1 Steps 2–3 and Task 4 Steps 1–2 remain open and are the operator's, on a deployed build. The four-step gate is written up in docs/authentication-methods.md Phase 5 and runbook D6:

  1. Log in with the normal button (it now goes through SuperTokens).
  2. signinup must return user.id equal to the existing users.id, not a UUID.
  3. identities.supertokens_user_id must hold a real SuperTokens id, not our own.
  4. shadow:check still 6/6 with no new identities.

Docs in five places that called supertokens mode unusable now say what is actually true, without claiming it is proven.

Order of operations

This was opened as a draft "until the gate has been run", which was circular — the gate needs a build carrying the fix, and that build only exists once this is merged and tagged. The gate gates AUTH_MODE=supertokens, not this merge.

  1. Merge, then tag main (never the branch) — the tag push is what publishes to GHCR. Without it the Unraid box cannot get the fix.
  2. Update the container. It stays on AUTH_MODE=dual; nothing about logins changes yet, because dual keeps accepting existing sessions either way.
  3. Run the four-step gate above.
  4. Only then consider AUTH_MODE=supertokens.

Merging is safe ahead of the gate: dual remains the resting state, legacy 90-day JWT cookies stay valid throughout, and rollback is still AUTH_MODE=passport + restart.

🤖 Generated with Claude Code

Evan Phyillaier and others added 3 commits August 8, 2026 12:13
init.js handed the provider list to ThirdParty.init as `signInUpFeature`.
The SDK reads `signInAndUpFeature`. The key is optional in its TypeInput and
JavaScript has no excess-property check at runtime, so the wrong spelling was
dropped in silence - no throw, no warning, no log.

The result: a stack that initialises cleanly, passes every existing test, logs
`providers=github,discord` (it logs what buildProviders returned, not what the
recipe received), and then answers every /auth/authorisationurl with
400 "the provider github could not be found in the configuration". That is
what production returns today on v1.8.4, for both providers.

v1.8 tested both halves and never the seam: buildProviders() returns the right
list, init refuses to start when the list is empty, and nothing asserted the
list reaches the recipe. buildProviders' own empty-list guard cannot catch this
- the list is non-empty, it just never arrives. `dual` hid it entirely, because
the client still logs in through passport and nothing reached the endpoint.

The new test runs the config init.js actually passes through the SDK's own
normaliser, so it fails on a wrong key and on a future SDK rename alike, rather
than merely restating the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client half of Phase 5. Three hand-rolled fetch calls rather than
supertokens-web-js, whose signOut() targets the /auth/signout v1.8 removed on
purpose - it clears only the SuperTokens half of a dual-stack session.

GET /api/auth-info (public) is how the client learns which stack to drive. It
cannot live on /api/config as the plan proposed: that route is behind
requireAuth and the caller is by definition not logged in yet. `loginFlow` is
the server's decision rather than something the client re-derives, so the
policy lives in one place - in `dual` it says `supertokens`, because exercising
that path is the entire point of dual.

Both stacks work from one bundle. Hardcoding SuperTokens would break
`passport`, which is still the default and the documented rollback.

Session refresh serialises through one shared promise. A refresh per 401 would
send N concurrent calls presenting the SAME refresh token; SuperTokens rotates
it on use, so calls 2..N look like token theft to the core and it revokes the
session - a routine renewal becoming a forced logout, and only under
concurrency. Same shape as server/userLock.js.

Two of the new tests were confirmed by mutation rather than by inspection:
deleting the single-flight guard makes the concurrency test report 3 refreshes
instead of 1, and disabling the client's callback leg fails two smoke checks.
The first version of the concurrency test passed with the guard deleted (its
stub keyed "already expired" off the URL, so only one of the three requests
ever got a 401) - the comment in the test records that so it cannot regress.

627 -> 696 vitest (SQLite), 39 -> 49 smoke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The docs said `supertokens` mode was unusable because the client had no login
flow. That is no longer the reason it should be approached carefully, so the
five places that said so - README, .env.example, unraid-template.xml, the
rollout runbook's preamble and D6, and authentication-methods.md Phase 5 - now
say what is actually true.

They do not say it is proven. The provider-registration fix IS verified against
a real SuperTokens core 12: booted in dual, the request production answers with
400 returns 200 and a valid GitHub authorize URL, and restoring the typo
reproduces production's error byte for byte. But POST /auth/signinup and the
identity mapping need a real OAuth round trip, so they are still stubbed
everywhere - and the mapping is the part that decides whether a returning
player lands on their own save or an empty one. D6 and Phase 5 now carry the
four-step gate to run on a real deployment before setting AUTH_MODE=supertokens.

Worth keeping from the real-core run: GET /api/auth-info reported
providers:["github","discord"] with the typo in place and without it. The
boot-side view is identical either way, which is why a whole release shipped
with no SuperTokens login possible and nothing anywhere said so.

696 vitest (SQLite), 722 (Postgres), 49 smoke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NeverEndingCode
NeverEndingCode force-pushed the worktree-v1.9-supertokens-client branch from 8e08137 to 37a61dc Compare August 8, 2026 16:14
@NeverEndingCode
NeverEndingCode marked this pull request as ready for review August 8, 2026 16:14
@NeverEndingCode
NeverEndingCode merged commit 1f40e31 into main Aug 8, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant