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)_