Files
galaxy-game/ui/docs/auth-flow.md
T
Ilia Denisov a89048f6c5 docs(ui): finalize MVP plan structure and de-archaeologize topic docs
MVP web client (Phases 1-30) is complete; reorganize planning + living docs around that.

- PLAN.md kept as the staged MVP record (1-30) with a status block + pointers; removed the 31-36 stages, regression scenarios, and deferred-TODO section (moved out); fixed a stale cross-machine plan path.

- ui/PLAN-finalize.md (new): active web-finalization plan in 8 stages (visual system, a11y, i18n, error UX, PWA, build hygiene, docs, owner manual-QA loop); absorbs former Phases 33 and 35.

- ui/ROADMAP.md (new): post-MVP (Wails, Capacitor, realistic projection, acceptance + regression scenarios) and triaged deferred follow-ups.

- ui/docs/README.md (new): grouped topic-doc index.

- De-archaeologized all 20 ui/docs topic docs + ui/README.md + ui/core/README.md: stripped Phase-N build history, rewritten as current-state; deferred work now points at ROADMAP.md / PLAN-finalize.md. Docs-only; no code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 23:17:51 +02:00

178 lines
8.1 KiB
Markdown

# Auth Flow (UI)
The Galaxy UI client uses a two-step e-mail-code login: the user
submits an e-mail address, receives a six-digit code by mail, then
submits the code together with a freshly generated Ed25519 public
key. The gateway returns a `device_session_id`, which the client
persists for subsequent visits. This doc covers the UI side; the
backend behaviour, throttling, and account-creation rules are
authoritative in [`docs/FUNCTIONAL.md` §1](../../docs/FUNCTIONAL.md).
## Surface
- `ui/frontend/src/api/auth.ts``sendEmailCode`,
`confirmEmailCode`, and the `AuthError` taxonomy.
- `ui/frontend/src/lib/session-store.svelte.ts` — singleton
reactive state (`status`, `keypair`, `deviceSessionId`) plus
`init`, `signIn`, `signOut`.
- `ui/frontend/src/lib/revocation-watcher.ts` — minimal
`SubscribeEvents` watcher that triggers `signOut("revoked")` on
any non-aborted stream termination.
- `ui/frontend/src/routes/login/+page.svelte` — two-step form.
- `ui/frontend/src/routes/lobby/+page.svelte` — placeholder lobby
that issues the first authenticated `user.account.get`.
- `ui/frontend/src/routes/+layout.svelte` — route guard plus the
browser-not-supported blocker.
## State machine (`SessionStatus`)
```text
init()
┌─────────────┐
│ loading │
└──┬───────┬──┘
│ │
│ ▼
│ Ed25519 missing → unsupported
device id?
┌────┴────┐
│ │
▼ ▼
anonymous authenticated
│ │
signIn signOut(*)
└────────►│
anonymous
```
`signOut("revoked")` shares the same observable end state as
`signOut("user")`; the reason exists only for telemetry. Both
trigger the layout effect's `anonymous → /login` redirect.
## UX states and error mapping
The send-email-code endpoint deliberately returns a uniform
response shape regardless of whether the address is new, existing,
throttled, or rate-limited (see
[`docs/FUNCTIONAL.md` §1.2](../../docs/FUNCTIONAL.md)). The UI
therefore treats every 200 the same and never tries to distinguish
those branches.
| Condition | UI behaviour |
| ------------------------------------ | ------------------------------------------------------------------- |
| 200 from `send-email-code` | advance to step `code`, focus the code input |
| `invalid_request` from `send` | stay on step `email`, surface the gateway message |
| `service_unavailable` from `send` | stay on step `email`, surface "service is temporarily unavailable" |
| 200 from `confirm-email-code` | persist `device_session_id`, redirect to `/lobby` |
| `invalid_request` from `confirm` | bounce to step `email`, message: "code expired or already used" |
| any other error from `confirm` | stay on step `code`, surface the gateway message |
`permanent_block` and any other authoritative rejection from the
backend collapse into the same `invalid_request` envelope from the
UI's perspective; the gateway does not differentiate them externally.
## Resend and change-email
- **send a new code** — re-issues `sendEmailCode` for the same
address. The backend may throttle by reusing the most recent
challenge id; the UI does not need to know about that.
- **change email** — clears the in-progress challenge and returns
to step `email`. No backend call.
## Persistence and returning users
After `confirm-email-code` succeeds, `session.signIn` writes the
`device_session_id` into the IDB cache (`namespace=session`,
`key=device-session-id`). On the next page load,
`SessionStore.init` reads it back and settles `status` to
`authenticated`, so the layout effect routes the user straight to
`/lobby`.
The keypair lives next to the id in the same database (object
store `keypair`, key `device`). Clearing site data wipes both;
the next load generates a fresh keypair and the user must log in
again. This is the documented re-login path — there is no paired
"reissue device session" flow.
## Browser support
The keystore relies on WebCrypto Ed25519, which currently lands in
Chrome ≥ 137, Firefox ≥ 130, Safari ≥ 17.4 (see
[`storage.md`](storage.md) for the rationale). On boot the layout
runs a sanity probe (`crypto.subtle.generateKey` for `Ed25519`); if
it rejects, the layout switches to a `browser not supported` page
instead of rendering `/login`. The client deliberately does not ship a
JavaScript Ed25519 fallback — the design decision is modern-browser
baseline only.
## Revocation
The lobby layout opens a long-running `SubscribeEvents` stream as
soon as `status` becomes `authenticated`. Its only contract is
liveness: any non-aborted termination of the stream is treated as
a server-side session revocation, the watcher calls
`session.signOut("revoked")`, and the layout effect redirects to
`/login`.
Session revocation closes the active client within one second: the
gateway closes the stream the moment it observes a
`session_invalidation` push event from backend, and the watcher
reacts on the next event-loop tick.
## Localisation
The login form, the root layout's blocker page, and the lobby
placeholder go through the i18n primitive in `src/lib/i18n/`. The
language picker on `/login` lists every entry in
`SUPPORTED_LOCALES` by its native name and is initialised from
`navigator.languages` (web) with `en` as the fallback. Picking a
different language re-renders the form in place and is forwarded
to the gateway in the JSON body of `send-email-code` (`locale`
field) — the body channel is the canonical one because Safari
drops JS-set `Accept-Language` headers. See
[`i18n.md`](i18n.md) for the architecture and the recipe for
adding a new language.
The locale is **not** persisted between page reloads; detection
runs again on every visit. Persistence and message-format
pluralisation are deferred to the finalization plan
(../Plan-finalize.md).
## Configuration
Build-time environment, read by `lib/env.ts`:
| Variable | Format | Notes |
| ------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `VITE_GATEWAY_BASE_URL` | URL string | gateway public REST surface and Connect-Web edge listener (same host); defaults to `http://localhost:8080` |
| `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` | standard base64, 32 raw Ed25519 bytes | response-signing public key; only needed on authenticated routes |
For local development against the integration suite, use the
public key the gateway container exposes (`ResponseSignerPublic` in
`integration/testenv/gateway.go`). Playwright derives both halves
of the pair from `tests/e2e/fixtures/gateway-key.ts` and pins the
public half through `playwright.config.ts`'s `webServer.env`.
An empty `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` does not block app
boot; the lobby surfaces an inline error when the first
`executeCommand` would have otherwise been issued.
## Testing
- **Vitest** — `tests/auth-api.test.ts`, `tests/session-store.test.ts`,
`tests/login-page.test.ts` cover the wire shape, persistence
state machine, and the form behaviours respectively.
- **Playwright** — `tests/e2e/auth-flow.spec.ts` exercises the
full happy path, returning-user resume, revocation within one
second, and the browser-not-supported blocker. The gateway is
mocked via `page.route(...)`; the lobby's `user.account.get`
call is answered with a fixture-signed `ExecuteCommandResponse`.
The Go-side integration suite (`integration/auth_flow_test.go`)
covers the live wire contract; this UI doc deliberately stops at
the boundaries above.