Files
galaxy-game/ui/docs/auth-flow.md
Ilia Denisov 9101aba816 phase 7+: i18n primitive + login language picker + autocomplete-off
Adds a minimal Svelte 5 i18n primitive (`src/lib/i18n/`) backing the
login form, the layout blocker page, and the lobby placeholder.
SUPPORTED_LOCALES drives both the picker and the runtime lookup;
adding a language is a two-step change inside `src/lib/i18n/`.

Login form gains a globe-icon language dropdown (English / Русский
in their native names), defaulting to navigator.languages with `en`
as the fallback. Switching the locale re-renders the form in place;
on submit, the locale rides in the JSON body of `send-email-code`
because Safari/WebKit silently drops JS-set Accept-Language. Gateway
gains a body `locale` field that takes priority over the request
header for preferred-language resolution.

Email and code inputs disable browser autofill / suggestions
(`autocomplete=off` + `autocorrect=off` + `autocapitalize=off` +
`spellcheck=false`) so Keychain / address-book pickers and
remembered-value dropdowns no longer fire on focus.

Cross-cuts:
- backend & gateway openapi: clarify that body `locale` is honored.
- docs/FUNCTIONAL{,_ru}.md §1.2: document body-vs-header priority.
- gateway tests: body `locale` overrides Accept-Language; blank
  body `locale` falls back to header.
- new ui/docs/i18n.md; cross-links from auth-flow.md and ui/README.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 16:14:40 +02:00

8.3 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 in Phase 7.

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. Phase 7 deliberately does not ship a JavaScript Ed25519 fallback — see Phase 6's "modern-browser baseline, no JS Ed25519 fallback" decision.

Revocation

The lobby layout opens a long-running SubscribeEvents stream as soon as status becomes authenticated. The watcher does not process individual events in Phase 7 — that arrives in Phase 24. 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.

This satisfies the Phase 7 acceptance bar of "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. Phase 35's full polish pass will revisit persistence and add message-format pluralisation.

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.