From 6adc8c67cd86cc04f22926e4d9f1cd3baf9e33fd Mon Sep 17 00:00:00 2001 From: Evan Phyillaier Date: Sat, 8 Aug 2026 01:25:16 -0400 Subject: [PATCH] Plan Phase 5: the SuperTokens client integration (v1.9) Written at the end of a session so the next one can start cold. Deliberately a plan and not a half-built implementation - partial frontend auth is worse than none. Task 1 needs no code at all: drive the SuperTokens login by hand with two curl calls against production dual, and check user.id comes back as github:37058311 rather than a UUID. That is the first time the signInUp mapping ever runs for real - it is exhaustively unit-tested, including the ordering guarantee, but dual does not exercise it because the client still logs in through passport. If Task 1 fails, everything after it is premature. Records the decision to hand-roll the three fetch calls rather than adopt supertokens-web-js, whose signOut() targets the /auth/signout that v1.8 deliberately removed, and which pulls in more surface than the allowlist permits. Also carries forward the constraints a fresh session would otherwise have to rediscover: users.id never changes, nobody gets logged out, the allowlist IS the SuperTokens HTTP surface, signinup takes only the redirect flow, and the five documents that currently say supertokens mode is unusable and will need un-blocking together. Docs only. Co-Authored-By: Claude Opus 5 --- docs/authentication-methods.md | 7 + .../2026-08-08-v1.9-supertokens-client.md | 223 ++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-08-v1.9-supertokens-client.md diff --git a/docs/authentication-methods.md b/docs/authentication-methods.md index 1e97ec7..d3d2aa7 100644 --- a/docs/authentication-methods.md +++ b/docs/authentication-methods.md @@ -219,6 +219,13 @@ evidence that the SuperTokens path works end to end. ## Phase 5 — `supertokens` mode — blocked on client work +> **Implementation plan written 2026-08-08:** +> [`superpowers/plans/2026-08-08-v1.9-supertokens-client.md`](./superpowers/plans/2026-08-08-v1.9-supertokens-client.md). +> Not started. Task 1 of that plan drives the SuperTokens login by hand against +> production `dual` — no code — which is the cheapest way to find out whether +> the identity mapping actually works end to end. Do that before writing any +> client code. + **This is the honest state: `supertokens`-only mode cannot be used yet, and the blocker is larger than "not recommended".** diff --git a/docs/superpowers/plans/2026-08-08-v1.9-supertokens-client.md b/docs/superpowers/plans/2026-08-08-v1.9-supertokens-client.md new file mode 100644 index 0000000..451e220 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-v1.9-supertokens-client.md @@ -0,0 +1,223 @@ +# v1.9 — SuperTokens client integration (Phase 5) + +> **For agentic workers:** REQUIRED SUB-SKILL: use `superpowers:subagent-driven-development` +> or `superpowers:executing-plans`. Steps use `- [ ]` checkboxes. + +**Status: NOT STARTED.** Written 2026-08-08 at the end of a session, so the next +one can begin immediately. + +**Goal:** teach the client to authenticate through SuperTokens, so +`AUTH_MODE=supertokens` becomes usable and the `signInUp` mapping is exercised +by real logins. + +## Where things stand (read this first) + +`AUTH_MODE=dual` is **live in production** on Unraid, RackStack v1.8.4, core 12 +at `192.168.68.50:3567`. Both gates passed against the real box: +`shadow:check` 6/6 100%, `supertokens:check` all 10 PASS. + +The **entire server side is built, tested and deployed.** Nothing in this plan +requires server work unless a step below says so explicitly. + +What is missing is only the client: + +1. **No SuperTokens login flow.** `client/src/Login.jsx` hardcodes + `` and `` — the *passport* + routes. `supertokens` mode does not register them, so the buttons fall + through to the SPA and nobody can sign in. +2. **No token refresh.** No frontend SDK, so nothing renews an expired access + token. Invisible in `dual` (the legacy JWT cookie still authenticates); + in `supertokens`-only mode a player is silently logged out when the access + token expires and their legacy cookie has also lapsed. + +**Consequence worth internalising:** because the client logs in via passport, +`dual` has never exercised the SuperTokens `signInUp` mapping. It is thoroughly +unit-tested — including the ordering guarantee that the user-id mapping is +created before the session — but has never run against a real GitHub login. +**Step 1 below is the first time that path executes for real.** + +## Constraints (all still in force from v1.8) + +- **`users.id` never changes.** It is `provider:providerId`. The mapping puts + SuperTokens' internal id *onto* it; `session.getUserId()` must return + `github:37058311`. +- **Nobody gets logged out.** Legacy 90-day JWT cookies stay valid through + every transition, both directions. Rollback is `AUTH_MODE=passport` + restart. +- **The allowlist is the SuperTokens HTTP surface.** `server/app.js`'s + `gateSuperTokensPaths` permits only `/auth/authorisationurl`, + `/auth/signinup`, `/auth/session/refresh` and `/auth/callback/*`. Anything + else the client needs must be added there **deliberately** — that gate exists + because `supertokens.init()` auto-adds six recipes and 13 endpoints, + including a second half-logout (`POST /auth/oauth/logout`). Do not widen it + casually. +- **`POST /auth/signinup` accepts only the redirect flow.** Submitting + `oAuthTokens` directly is rejected (`rejectRawOAuthTokens`) — that was an + account-takeover bypass. The client must use `redirectURIInfo`. +- **`/auth/signout` is deliberately removed.** Use `/auth/logout`, which clears + both stacks. The SuperTokens frontend SDK's `signOut()` targets + `/auth/signout` — if you adopt the SDK, point it at `/auth/logout` or the + logout will half-work. +- **Both backends still tested.** `npm run test:all` green on SQLite and + Postgres. +- **Docs updated in the same task that changes behaviour.** + +## Decision to make before Task 1 + +**Use `supertokens-web-js`, or hand-roll the three fetch calls?** + +| | SDK | Hand-rolled | +|---|---|---| +| Refresh | Handled, incl. concurrent-request queueing | Must write it | +| Bundle | +~30 kB | none | +| Surface | Pulls in more than we allowlist | Exactly three calls | +| Risk | Its `signOut()` hits the removed `/auth/signout` | Fully under our control | + +**Recommendation: hand-roll.** The flow is three calls, the allowlist is +already exactly those three, and the SDK's assumptions (its own signout path, +its own session storage) cut against decisions v1.8 made deliberately. Revisit +if refresh-under-concurrency proves fiddly. + +--- + +### Task 1: Prove the SuperTokens login path works at all + +**Do this before writing any client code.** It is the cheapest possible way to +find out whether the mapping works end to end, and it needs no code. + +- [ ] **Step 1: Drive the flow by hand against production `dual`** + +```bash +# 1. Get the provider URL (returns JSON with urlWithQueryParams) +curl -s 'https://rackstack.neverendingcode.com/auth/authorisationurl?thirdPartyId=github&redirectURIOnProviderDashboard=https://rackstack.neverendingcode.com/auth/callback/github' +``` + +Open `urlWithQueryParams` in a browser, authorise, and let GitHub redirect to +`/auth/callback/github?code=…&state=…`. Copy those query params, then: + +```bash +curl -s -X POST 'https://rackstack.neverendingcode.com/auth/signinup' \ + -H 'Content-Type: application/json' \ + -d '{"thirdPartyId":"github","redirectURIInfo":{ + "redirectURIOnProviderDashboard":"https://rackstack.neverendingcode.com/auth/callback/github", + "redirectURIQueryParams":{"code":"","state":""}}}' +``` + +- [ ] **Step 2: Assert the thing the whole release rests on** + +Expect `status: "OK"` and **`user.id === "github:37058311"`** — your existing +`users.id`, not a SuperTokens UUID. If it is a UUID, the mapping did not run; +stop and debug before writing any client code. + +Then confirm on the box: + +```sql +SELECT provider, provider_id, supertokens_user_id FROM identities; +``` + +`supertokens_user_id` should now be populated for that row with a real +SuperTokens id (**not** `github:37058311` — writing our own id back there was a +bug fixed in v1.8; if you see it, that regressed). + +- [ ] **Step 3: Re-run both gates** + +`npm run shadow:check` must still be 6/6 with **0 new identities** — a new row +would mean the login created a second account instead of matching the existing +one, which is the exact failure v1.8 exists to prevent. + +> If Task 1 fails, everything below is premature. Fix the mapping first. + +--- + +### Task 2: The login flow in the client + +**Files:** `client/src/Login.jsx`, new `client/src/game/auth.js` + +- [ ] **Step 1: Detect which stack to drive** + +The client must not hardcode either. Add `authMode` to `GET /api/config` +(server-side, one line — it is the only server change in this plan) and have +`Login.jsx` render passport `` links when passport is enabled, and +SuperTokens buttons when it is not. + +Rationale: hardcoding SuperTokens would break `passport` mode, which is still +the default and the documented rollback. Both must work from one build. + +- [ ] **Step 2: Implement `startSuperTokensLogin(providerId)`** + +`GET /auth/authorisationurl?thirdPartyId=&redirectURIOnProviderDashboard=/auth/callback/` +→ `window.location.assign(urlWithQueryParams)`. Persist nothing; the state +param round-trips through the provider. + +- [ ] **Step 3: Handle the callback route** + +`/auth/callback/:provider` currently falls to the SPA. Have the app detect that +path on load, `POST /auth/signinup` with `redirectURIInfo` built from +`window.location.search`, then replace history with `/` on success. + +`credentials: 'include'` on both calls, or the session cookie is dropped. + +- [ ] **Step 4: Failure paths** + +`GENERAL_ERROR` (the raw-token refusal), `SIGN_IN_UP_NOT_ALLOWED`, and a +provider `error=access_denied` must each land the player back on the login +screen with a readable message — reuse the existing `?authError=` convention. + +- [ ] **Step 5: Tests** + +An e2e smoke suite (`tests/e2e/smoke-v19.mjs`, matching the existing +`smoke-v1*.mjs` glob) that stubs the provider and drives the full flow against +a real app in `dual`, asserting the session resolves to the pre-existing +`users.id` and the existing save. + +--- + +### Task 3: Session refresh + +**Files:** `client/src/game/api.js` + +- [ ] **Step 1: Refresh on 401** + +Wrap the existing fetch helper: on a 401, `POST /auth/session/refresh` once, +then retry the original request. On a second 401, fall through to the login +screen. + +- [ ] **Step 2: Serialize concurrent refreshes** + +The app fires several requests at once; a naive implementation sends N refresh +calls and races them. One in-flight refresh promise, shared — the same shape as +`server/userLock.js`. **This is the part most likely to be got wrong.** + +- [ ] **Step 3: Do not break passport mode** + +In `passport` mode a 401 means "not logged in" and there is nothing to refresh. +Gate the refresh attempt on the same `authMode` from Task 2 Step 1. + +- [ ] **Step 4: Test both** + +A 401→refresh→retry succeeds; two concurrent 401s produce exactly **one** +refresh call; passport mode never calls refresh. + +--- + +### Task 4: Cutover to `supertokens` and release + +- [ ] **Step 1: Verify in `dual` first** — with the client on SuperTokens and + `AUTH_MODE=dual`, a legacy cookie must *still* authenticate. That is the + no-forced-logout guarantee under the new client. +- [ ] **Step 2: `AUTH_MODE=supertokens`** on the box; confirm login, refresh + across an access-token expiry, and logout clearing both stacks. +- [ ] **Step 3: Un-block the docs.** `docs/authentication-methods.md` Phase 5, + the runbook's D6 and "what has NOT been verified", `README.md`, + `.env.example` and `unraid-template.xml` all currently say + `supertokens` mode is unusable. That stops being true here — update all + five, and do not leave a stale warning behind. +- [ ] **Step 4: Version, changelog, tag** — `package.json` + Dockerfile label + (`client/package.json` is deliberately NOT bumped; `client/vite.config.js` + reads the root as the single version authority). Tag `main`, never the + branch — that push is what triggers the GHCR publish. + +--- + +## Findings and deviations + +_(record as work proceeds, so the plan does not quietly diverge)_