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