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>
8.8 KiB
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.
Surface
ui/frontend/src/api/auth.ts—sendEmailCode,confirmEmailCode, and theAuthErrortaxonomy.ui/frontend/src/lib/session-store.svelte.ts— singleton reactive state (status,keypair,deviceSessionId) plusinit,signIn,signOut.ui/frontend/src/lib/revocation-watcher.ts— minimalSubscribeEventswatcher that triggerssignOut("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 authenticateduser.account.get.ui/frontend/src/routes/+page.svelte— the state-based auth gate / screen dispatcher (anonymous → login, authenticated → theappScreenscreen). The single-URL app-shell has no per-screen routes; seenavigation.md.ui/frontend/src/routes/+layout.svelte— boot-time session init, theloading/unsupportedinterception, and the browser-not-supported blocker.
State machine (SessionStatus)
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). 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
sendEmailCodefor 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), 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 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 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.tscover the wire shape, persistence state machine, and the form behaviours respectively. - Playwright —
tests/e2e/auth-flow.spec.tsexercises the full happy path, returning-user resume, revocation within one second, and the browser-not-supported blocker. The gateway is mocked viapage.route(...); the lobby'suser.account.getcall is answered with a fixture-signedExecuteCommandResponse.
The Go-side integration suite (integration/auth_flow_test.go)
covers the live wire contract; this UI doc deliberately stops at
the boundaries above.