e31fb2c17a
Tests · UI / test (push) Failing after 9m28s
Rewrite ui/docs (navigation, order-composer, auth-flow, pwa-strategy, game-state + secondary topic docs) and ui/README for the single-URL app-shell (in-memory screens/views, Back→lobby via shallow routing, sessionStorage restore + validation, return-to-lobby). ui/PLAN.md gets a Phase-10 supersede note (implemented; standalone-compatible). Fix stale code comments (session-store auth gate, report-sections spec contract). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
8.8 KiB
Markdown
187 lines
8.8 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/lib/screens/login-screen.svelte` — two-step form.
|
|
- `ui/frontend/src/lib/screens/lobby-screen.svelte` — lobby that
|
|
issues the first authenticated `user.account.get`.
|
|
- `ui/frontend/src/routes/+page.svelte` — the state-based auth gate /
|
|
screen dispatcher (anonymous → login, authenticated → the
|
|
`appScreen` screen). The single-URL app-shell has no per-screen
|
|
routes; see [`navigation.md`](navigation.md).
|
|
- `ui/frontend/src/routes/+layout.svelte` — boot-time session init,
|
|
the `loading` / `unsupported` interception, and 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 settle
|
|
`status` to `anonymous`, which the dispatcher renders as the login
|
|
screen — there is no URL redirect (the app-shell stays at `/game/`).
|
|
|
|
## 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`, settle `status` to `authenticated` (dispatcher shows the 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 dispatcher renders the authenticated screen
|
|
straight away. Which authenticated screen it is comes from the
|
|
restored `appScreen` snapshot (lobby by default; see
|
|
[`navigation.md`](navigation.md)), not from the URL.
|
|
|
|
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 root
|
|
layout runs a sanity probe (`crypto.subtle.generateKey` for
|
|
`Ed25519`); if it rejects, `status` settles to `unsupported` and the
|
|
layout renders a `browser not supported` page instead of the login
|
|
screen. The client deliberately does not ship a JavaScript Ed25519
|
|
fallback — the design decision is modern-browser baseline only.
|
|
|
|
## Revocation
|
|
|
|
The root 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")`, `status` settles to `anonymous`, and
|
|
the dispatcher swaps to the login screen on the next render — the
|
|
URL stays `/game/`.
|
|
|
|
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
|
|
screen go through the i18n primitive in `src/lib/i18n/`. The
|
|
language picker on the login screen 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.
|