Files
galaxy-game/ui/docs/auth-flow.md
T
Ilia Denisov e31fb2c17a
Tests · UI / test (push) Failing after 9m28s
docs(ui): sync docs to the app-shell; fix stale nav comments
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>
2026-05-23 21:04:11 +02:00

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.tssendEmailCode, 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.
  • ui/frontend/src/routes/+layout.svelte — boot-time session init, the loading / unsupported interception, 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 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), 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

  • Vitesttests/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.
  • Playwrighttests/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.