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

8.1 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/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)

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