Implements ui/PLAN.md Phase 7 end-to-end: - /login two-step form (email -> code) over the gateway public REST surface; /lobby placeholder issues the first authenticated user.account.get and renders the decoded display name. - SessionStore (Svelte 5 runes) with loading / unsupported / anonymous / authenticated states; layout-level route guard, browser-not-supported blocker, and a minimal SubscribeEvents revocation watcher that closes the active client within 1s on a clean stream end or Unauthenticated. - VITE_GATEWAY_BASE_URL + VITE_GATEWAY_RESPONSE_PUBLIC_KEY config plus AuthError taxonomy in api/auth.ts. - Vitest (auth-api, session-store, login-page) and Playwright e2e (auth-flow.spec.ts) on the four configured projects, with a fixture Ed25519 keypair forging Connect-Web JSON responses. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7.5 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/routes/login/+page.svelte— two-step form.ui/frontend/src/routes/lobby/+page.svelte— placeholder lobby that issues the first authenticateduser.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
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 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.
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.