diff --git a/ui/PLAN.md b/ui/PLAN.md index 849e8de..1168281 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -677,54 +677,112 @@ Targeted tests: including non-deterministic WebKit), and that `clearDeviceSession` forces a fresh keypair on next load. -## Phase 7. Auth Flow UI +## ~~Phase 7. Auth Flow UI~~ -Status: pending. +Status: done. Goal: implement the full email-code login flow with device session registration and post-login redirect to a placeholder lobby. -Artifacts: +Decisions taken with the project owner before implementation: -- `ui/frontend/src/routes/login` two-step form (email → code) -- `ui/frontend/src/api/auth.ts` wraps `public.auth.send_email_code` and - `public.auth.confirm_email_code`, registers the public key, persists - via `KeyStore` -- `ui/frontend/src/lib/session-store.ts` Svelte store exposing the - current session state -- `ui/frontend/src/routes/+layout.svelte` redirect to `/login` for - unauthenticated routes; redirect to `/lobby` on successful confirm -- placeholder `ui/frontend/src/routes/lobby/+page.svelte` rendering - `you are logged in` -- topic doc `ui/docs/auth-flow.md` describing error UX, code - resend, expired challenge handling, and re-login on revocation +1. **Playwright e2e against a mocked gateway.** `page.route(...)` + intercepts the public auth REST surface and the Connect-Web + `ExecuteCommand` / `SubscribeEvents` URLs; a fixture Ed25519 key in + `tests/e2e/fixtures/gateway-key.ts` signs the forged responses so + `GalaxyClient.verifyResponse` accepts them under the matching + public key the dev server picks up via + `VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. The wire-contract path is + already covered by the Go integration suite + (`integration/auth_flow_test.go`). +2. **Build-time gateway response public key delivery.** The browser + reads `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` (standard base64 of the + raw 32-byte key) on module load. A future phase may switch to a + `/api/v1/public/well-known/...` endpoint when prod distribution is + wired up; Phase 7 stops at the env-var. +3. **Minimal SubscribeEvents-based revocation watcher.** The lobby + layout opens a long-running stream and treats two outcomes as + revocation: a clean end-of-stream (the gateway closing after a + `session_invalidation` event) and a Connect `Unauthenticated` + error. Network errors and `Canceled` aborts stay silent so a + flaky connection or page navigation does not bounce the user. The + per-event dispatch path lands in Phase 24. +4. **Browser-not-supported blocker.** The root layout runs a one-time + `crypto.subtle.generateKey({name:"Ed25519"}, ...)` probe on boot + and renders a blocker page when the probe rejects. This closes + Phase 6's "no JS Ed25519 fallback" follow-up. + +Artifacts (delivered): + +- `ui/frontend/src/routes/login/+page.svelte` (+ `+page.ts` with + `prerender = false; ssr = false;`) — two-step form (email → code) + with resend and change-email affordances. +- `ui/frontend/src/routes/lobby/+page.svelte` (+ `+page.ts`) — + placeholder lobby that issues the first authenticated + `user.account.get` through `GalaxyClient` and surfaces the decoded + display name. +- `ui/frontend/src/routes/+layout.svelte` — boot-time session init, + route guard (anonymous → `/login`, authenticated on `/login` → + `/lobby`), browser-not-supported blocker, and the revocation + watcher lifecycle. `+layout.ts` puts the whole tree into SPA mode + (`ssr = false; prerender = false;`). +- `ui/frontend/src/api/auth.ts` — `sendEmailCode`, + `confirmEmailCode`, and the `AuthError` taxonomy over + `/api/v1/public/auth/*`. +- `ui/frontend/src/lib/env.ts` — `GATEWAY_BASE_URL`, + `GATEWAY_RESPONSE_PUBLIC_KEY` (decoded once on module load). +- `ui/frontend/src/lib/session-store.svelte.ts` — `SessionStore` + singleton (Svelte 5 runes); states `loading | unsupported | + anonymous | authenticated`; `init`, `signIn`, `signOut("user" | + "revoked")`. +- `ui/frontend/src/lib/revocation-watcher.ts` — opens + `SubscribeEvents` against the gateway, signs the envelope through + `Core.signRequest`, treats clean stream end / `Unauthenticated` as + revocation. +- `ui/frontend/.env.example` — `VITE_GATEWAY_BASE_URL`, + `VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. +- Topic doc `ui/docs/auth-flow.md`; cross-references from + `ui/docs/storage.md` and `ui/README.md`. +- Vitest: `tests/auth-api.test.ts`, `tests/session-store.test.ts`, + `tests/login-page.test.ts`. +- Playwright: `tests/e2e/auth-flow.spec.ts` (4 cases × 4 projects) + with the fixture key plumbing in + `tests/e2e/fixtures/{gateway-key,canon,sign-response}.ts`. +- Pre-existing `tests/e2e/landing.spec.ts` was deleted; the landing + surface is no longer reachable in the auth-gated app and the + Vitest unit test on `routes/+page.svelte` retains the version + footer assertion. Dependencies: Phase 6. -Acceptance criteria: +Acceptance criteria (met): -- a fresh browser completes login end-to-end against a local - gateway+backend stack; -- the first authenticated Connect call after login (e.g. - `user.account.read`) succeeds end-to-end through `WasmCore` → - `GalaxyClient` → ConnectRPC → gateway, with the response signature - verified and the payload decoded back to JSON. This bullet - subsumes the gateway-acceptance check originally listed in - Phase 6; the Phase 6 storage layer cannot pass it without - persisting and signing correctly; -- a returning browser resumes the session without re-login; -- gateway-side session revocation closes the active client immediately - and routes back to `/login`. +- A fresh browser completes login end-to-end via the mocked gateway + in all four Playwright projects; the first authenticated Connect + call (`user.account.get`) succeeds end-to-end through `WasmCore` → + `GalaxyClient` → ConnectRPC and the response signature is verified + under `VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. This bullet subsumes the + gateway-acceptance check originally listed in Phase 6. +- A returning browser resumes the session without re-login (covered + by `tests/e2e/auth-flow.spec.ts::"returning user lands on the + lobby without re-login"`). +- Gateway-side session revocation closes the active client within one + second and routes back to `/login` (covered by + `tests/e2e/auth-flow.spec.ts::"server-side revocation closes the + active client within one second"`). -Targeted tests: +Targeted tests (delivered): -- Vitest component tests for the login forms with mocked - `GalaxyClient`; -- Playwright e2e test driving the full flow against a local stack in - desktop and mobile viewports, asserting the first authenticated - Connect call returns successfully after login; -- regression test for revocation: server-side revoke causes client - redirect within one second. +- Vitest component tests for the login form with mocked `auth.ts` + (six cases: email step, error mapping, code step, expired-code + bounce, resend, change-email). +- Vitest tests for `SessionStore` (init, signIn/signOut, support + probe, idempotency) and for the auth REST wrappers (URL/body + shape, base64 public key, `AuthError` mapping). +- Playwright e2e suite (`auth-flow.spec.ts`) on + chromium-desktop / webkit-desktop / chromium-mobile-iphone-13 / + chromium-mobile-pixel-5: fresh login, returning user, revocation + within one second, browser-not-supported blocker. ## Phase 8. Lobby UI diff --git a/ui/README.md b/ui/README.md index ce496ea..e14345e 100644 --- a/ui/README.md +++ b/ui/README.md @@ -63,21 +63,26 @@ ui/ ├── README.md this file ├── buf.gen.yaml local-plugin TS Protobuf-ES generator ├── docs/ topic-based design notes +│ ├── auth-flow.md email-code login, session store, revocation │ ├── storage.md web KeyStore/Cache, IDB schema, baseline │ ├── testing.md per-PR / release test tiers │ └── wasm-toolchain.md TinyGo build, JSDOM loading, bundle budget ├── core/ ui/core Go module (canonical bytes, keypair) ├── wasm/ TinyGo entry point exposing Core to JS └── frontend/ SvelteKit / Vite source - ├── src/api/ GalaxyClient + typed Connect client + session + ├── src/api/ GalaxyClient + typed Connect client + auth + session + ├── src/lib/ env config, session store, revocation watcher ├── src/platform/core/ Core interface + WasmCore adapter ├── src/platform/store/ KeyStore/Cache interfaces + web adapter ├── src/proto/ generated Protobuf-ES + Connect descriptors + ├── src/routes/ SvelteKit routes (/, /login, /lobby) └── static/ core.wasm + wasm_exec.js (committed artefacts) ``` Linked topic docs: +- [`docs/auth-flow.md`](docs/auth-flow.md) — email-code login, + session store state machine, revocation watcher. - [`docs/storage.md`](docs/storage.md) — web KeyStore/Cache, IndexedDB schema, browser baseline. - [`docs/wasm-toolchain.md`](docs/wasm-toolchain.md) — TinyGo build, diff --git a/ui/docs/auth-flow.md b/ui/docs/auth-flow.md new file mode 100644 index 0000000..8358fc0 --- /dev/null +++ b/ui/docs/auth-flow.md @@ -0,0 +1,160 @@ +# 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](../../docs/FUNCTIONAL.md). + +## Surface + +- `ui/frontend/src/api/auth.ts` — `sendEmailCode`, + `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`) + +```text + 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](../../docs/FUNCTIONAL.md)). 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`](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.ts` cover the wire shape, persistence + state machine, and the form behaviours respectively. +- **Playwright** — `tests/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. diff --git a/ui/docs/storage.md b/ui/docs/storage.md index d4a6de8..71614fb 100644 --- a/ui/docs/storage.md +++ b/ui/docs/storage.md @@ -41,8 +41,10 @@ Browsers older than the baseline above will fail at the first `NotSupportedError`. Phase 6 deliberately does not ship a JavaScript fallback (e.g. `@noble/ed25519`) — keeping the keystore on WebCrypto is what gives us non-extractable storage on every supported engine. -The Phase 7 login UI surfaces a clear "browser not supported" -message instead. +The Phase 7 root layout runs a one-time probe on boot and switches +to a "browser not supported" page (described in +[`auth-flow.md`](auth-flow.md)) when the probe rejects, instead of +attempting the keystore generate. ### WebKit non-determinism note diff --git a/ui/frontend/.env.example b/ui/frontend/.env.example new file mode 100644 index 0000000..83131cb --- /dev/null +++ b/ui/frontend/.env.example @@ -0,0 +1,18 @@ +# Vite reads any variable prefixed with `VITE_` and exposes it on +# `import.meta.env`. Copy this file to `.env.local` (gitignored) and +# fill in the values before running `pnpm run dev` or `pnpm exec +# playwright test` against a real gateway. + +# Base URL of the gateway public REST surface and Connect-Web edge +# listener. Both surfaces share the same host and port. Defaults to +# the local dev address used by `tools/local-ci` and the Go-side +# integration suite. +VITE_GATEWAY_BASE_URL=http://localhost:8080 + +# Standard (non-URL-safe) base64 of the gateway's raw 32-byte +# Ed25519 response-signing public key. Required only for +# authenticated unary calls; unauthenticated routes (`/login`) +# work without it. For local dev, take the value the gateway +# integration container exports as `ResponseSignerPublic` (see +# `integration/testenv/gateway.go`). +VITE_GATEWAY_RESPONSE_PUBLIC_KEY= diff --git a/ui/frontend/playwright.config.ts b/ui/frontend/playwright.config.ts index b157aa0..9cc4531 100644 --- a/ui/frontend/playwright.config.ts +++ b/ui/frontend/playwright.config.ts @@ -1,4 +1,5 @@ import { defineConfig, devices } from "@playwright/test"; +import { FIXTURE_PUBLIC_KEY_RAW_BASE64 } from "./tests/e2e/fixtures/gateway-key"; export default defineConfig({ testDir: "tests/e2e", @@ -29,5 +30,13 @@ export default defineConfig({ url: "http://localhost:5173", reuseExistingServer: !process.env.CI, timeout: 120_000, + env: { + // The Phase 7 Playwright spec mocks the gateway and signs + // every response with the deterministic fixture key in + // `tests/e2e/fixtures/gateway-key.ts`. The dev server picks + // up the matching public key here so the in-page + // `GalaxyClient` accepts the forged signatures. + VITE_GATEWAY_RESPONSE_PUBLIC_KEY: FIXTURE_PUBLIC_KEY_RAW_BASE64, + }, }, }); diff --git a/ui/frontend/src/api/auth.ts b/ui/frontend/src/api/auth.ts new file mode 100644 index 0000000..94354eb --- /dev/null +++ b/ui/frontend/src/api/auth.ts @@ -0,0 +1,163 @@ +// Thin wrappers around the gateway public auth REST surface used by +// the email-code login flow. The two exported functions correspond +// 1:1 to the OpenAPI operations defined in +// `backend/openapi.yaml`: +// +// POST /api/v1/public/auth/send-email-code +// POST /api/v1/public/auth/confirm-email-code +// +// Both endpoints are unauthenticated — the device session does not +// exist yet during send-code, and confirm-code is the call that +// creates one. Persisting the returned `device_session_id` is the +// caller's responsibility (see `lib/session-store.svelte.ts`). +// +// `Accept-Language` is set automatically by the browser; the gateway +// reads it for the auth-mail localisation. We do not duplicate the +// value into the optional `locale` body field. + +const SEND_EMAIL_CODE_PATH = "/api/v1/public/auth/send-email-code"; +const CONFIRM_EMAIL_CODE_PATH = "/api/v1/public/auth/confirm-email-code"; + +export interface SendEmailCodeResult { + challengeId: string; +} + +export interface ConfirmEmailCodeInput { + challengeId: string; + code: string; + publicKey: Uint8Array; + timeZone: string; +} + +export interface ConfirmEmailCodeResult { + deviceSessionId: string; +} + +/** + * AuthError is thrown by `sendEmailCode` and `confirmEmailCode` for + * every non-2xx gateway response. `code` mirrors the stable + * machine-readable identifier from the gateway error envelope + * (`invalid_request`, `service_unavailable`, `internal_error`, ...); + * `status` is the HTTP status that produced the error. + */ +export class AuthError extends Error { + readonly code: string; + readonly status: number; + + constructor(code: string, message: string, status: number) { + super(message); + this.name = "AuthError"; + this.code = code; + this.status = status; + } +} + +/** + * sendEmailCode issues a login challenge for `email`. The gateway + * returns the same opaque `challenge_id` shape regardless of whether + * the address belongs to a new, existing, or throttled account, so + * the caller cannot use the response to enumerate accounts. + */ +export async function sendEmailCode( + baseUrl: string, + email: string, +): Promise { + const response = await fetch(joinUrl(baseUrl, SEND_EMAIL_CODE_PATH), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ email }), + }); + if (!response.ok) { + throw await readAuthError(response); + } + const body = (await response.json()) as { challenge_id?: unknown }; + if (typeof body.challenge_id !== "string" || body.challenge_id.length === 0) { + throw new AuthError( + "internal_error", + "gateway returned a malformed send-email-code response", + response.status, + ); + } + return { challengeId: body.challenge_id }; +} + +/** + * confirmEmailCode submits the verification code and the device's + * Ed25519 public key. On success the gateway returns the new device + * session identifier; persistence of that identifier is the caller's + * responsibility. + */ +export async function confirmEmailCode( + baseUrl: string, + input: ConfirmEmailCodeInput, +): Promise { + const response = await fetch(joinUrl(baseUrl, CONFIRM_EMAIL_CODE_PATH), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + challenge_id: input.challengeId, + code: input.code, + client_public_key: encodeBase64(input.publicKey), + time_zone: input.timeZone, + }), + }); + if (!response.ok) { + throw await readAuthError(response); + } + const body = (await response.json()) as { device_session_id?: unknown }; + if ( + typeof body.device_session_id !== "string" || + body.device_session_id.length === 0 + ) { + throw new AuthError( + "internal_error", + "gateway returned a malformed confirm-email-code response", + response.status, + ); + } + return { deviceSessionId: body.device_session_id }; +} + +async function readAuthError(response: Response): Promise { + let code = ""; + let message = ""; + try { + const body = (await response.json()) as { + error?: { code?: unknown; message?: unknown }; + }; + const err = body.error; + if (err && typeof err.code === "string") { + code = err.code; + } + if (err && typeof err.message === "string") { + message = err.message; + } + } catch { + // Body was not JSON or could not be parsed; fall through to + // generic defaults below. + } + if (code.length === 0) { + code = response.status >= 500 ? "internal_error" : "invalid_request"; + } + if (message.length === 0) { + message = + response.status >= 500 + ? "service is temporarily unavailable" + : `request rejected (${response.status})`; + } + return new AuthError(code, message, response.status); +} + +function joinUrl(baseUrl: string, path: string): string { + const trimmedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + const trimmedPath = path.startsWith("/") ? path : `/${path}`; + return `${trimmedBase}${trimmedPath}`; +} + +function encodeBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]!); + } + return btoa(binary); +} diff --git a/ui/frontend/src/lib/env.ts b/ui/frontend/src/lib/env.ts new file mode 100644 index 0000000..23c6e6a --- /dev/null +++ b/ui/frontend/src/lib/env.ts @@ -0,0 +1,47 @@ +// Build-time configuration for the Galaxy gateway. Both values arrive +// through Vite `import.meta.env` and resolve to module-level constants +// at the first import. +// +// `VITE_GATEWAY_BASE_URL` is the base URL of the gateway public REST +// surface and the Connect-Web authenticated edge (same host, same +// port; the gateway listener serves both). It defaults to the local +// dev address used by `tools/local-ci` and the integration suite. +// +// `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` is the gateway's response-signing +// Ed25519 public key, encoded as standard (non-URL-safe) base64 of +// the raw 32-byte key. Decoded once on module load and exported as +// `Uint8Array`. The value is only consumed by [GalaxyClient] when a +// signed unary call is dispatched; the unauthenticated routes do not +// need it. An empty or malformed value therefore does not block app +// boot — it surfaces only when the lobby route opens its first +// authenticated call. + +const RAW_BASE_URL: string = + (import.meta.env.VITE_GATEWAY_BASE_URL as string | undefined) ?? + "http://localhost:8080"; + +const RAW_RESPONSE_PUBLIC_KEY: string = + (import.meta.env.VITE_GATEWAY_RESPONSE_PUBLIC_KEY as string | undefined) ?? + ""; + +export const GATEWAY_BASE_URL: string = stripTrailingSlash(RAW_BASE_URL); + +export const GATEWAY_RESPONSE_PUBLIC_KEY: Uint8Array = decodeBase64( + RAW_RESPONSE_PUBLIC_KEY, +); + +function stripTrailingSlash(url: string): string { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function decodeBase64(value: string): Uint8Array { + if (value.length === 0) { + return new Uint8Array(); + } + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} diff --git a/ui/frontend/src/lib/revocation-watcher.ts b/ui/frontend/src/lib/revocation-watcher.ts new file mode 100644 index 0000000..8a587c5 --- /dev/null +++ b/ui/frontend/src/lib/revocation-watcher.ts @@ -0,0 +1,157 @@ +// `startRevocationWatcher` opens an authenticated SubscribeEvents +// stream against the gateway and treats any non-aborted termination +// as a session-revocation signal: the watcher calls +// `session.signOut("revoked")` so the root layout's anonymous redirect +// returns the user to `/login` immediately. +// +// Phase 7 deliberately ignores event payloads — the per-event +// dispatch (turn-ready toasts, mail invalidation, ...) lands in +// Phase 24. The wire envelope shape and signing rules are identical +// to `executeCommand`: the gateway's `canonicalSubscribeEventsValidation` +// enforces the same v1 envelope shape, and the canonical signing +// input is produced by `Core.signRequest`. The integration suite +// exercises the same flow in +// `integration/testenv/connect_client.go::SubscribeEvents` with the +// `gateway.subscribe` literal. + +import { create } from "@bufbuild/protobuf"; +import { ConnectError } from "@connectrpc/connect"; +import { createEdgeGatewayClient } from "../api/connect"; +import { loadCore } from "../platform/core/index"; +import { SubscribeEventsRequestSchema } from "../proto/galaxy/gateway/v1/edge_gateway_pb"; +import { GATEWAY_BASE_URL } from "./env"; +import { session } from "./session-store.svelte"; + +const PROTOCOL_VERSION = "v1"; +const SUBSCRIBE_MESSAGE_TYPE = "gateway.subscribe"; + +/** + * startRevocationWatcher opens a SubscribeEvents stream and returns a + * stop function. Calling the stop function aborts the in-flight + * stream silently; only stream terminations the watcher did not + * initiate trigger `session.signOut("revoked")`. + */ +export function startRevocationWatcher(): () => void { + const controller = new AbortController(); + void runWatcher(controller.signal); + return () => controller.abort(); +} + +async function runWatcher(signal: AbortSignal): Promise { + if ( + session.status !== "authenticated" || + session.keypair === null || + session.deviceSessionId === null + ) { + return; + } + const keypair = session.keypair; + const deviceSessionId = session.deviceSessionId; + + let stream: AsyncIterable; + try { + const core = await loadCore(); + const requestId = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : fallbackRequestId(); + const timestampMs = BigInt(Date.now()); + const emptyPayload = new Uint8Array(); + const payloadHash = await sha256(emptyPayload); + const canonical = core.signRequest({ + protocolVersion: PROTOCOL_VERSION, + deviceSessionId, + messageType: SUBSCRIBE_MESSAGE_TYPE, + timestampMs, + requestId, + payloadHash, + }); + const signature = await keypair.sign(canonical); + + const client = createEdgeGatewayClient(GATEWAY_BASE_URL); + const request = create(SubscribeEventsRequestSchema, { + protocolVersion: PROTOCOL_VERSION, + deviceSessionId, + messageType: SUBSCRIBE_MESSAGE_TYPE, + timestampMs, + requestId, + payloadHash, + signature, + payloadBytes: emptyPayload, + }); + stream = client.subscribeEvents(request, { signal }); + } catch (err) { + // A failure before the stream is opened (load core, signing, + // transport) is a transient setup error — log and bail out. + // Revocation is signalled later by the gateway closing an + // already-open stream. + if (!signal.aborted) { + console.info("session store: failed to open subscribe-events", err); + } + return; + } + + try { + for await (const _event of stream) { + void _event; + } + } catch (err) { + // Stream errors arrive on three different paths: + // 1. our own AbortController fired (page navigated, layout + // stopped the watcher) — `signal.aborted` is true; + // 2. the gateway revoked the session and Connect-Web maps + // that to `Unauthenticated` / `PermissionDenied`; + // 3. transient network failure (Wi-Fi drop, server + // restart) — anything else. + // + // Only branch 2 is a true revocation. Branch 1 is silent; + // branch 3 is logged but does not log the user out, so a + // flaky network does not bounce them back to /login. + if (signal.aborted) { + return; + } + const code = connectErrorCode(err); + if (code === ConnectErrorCode.Unauthenticated) { + await session.signOut("revoked"); + return; + } + console.info("session store: subscribe-events stream errored", err); + return; + } + // Clean end-of-stream from the gateway is the documented + // `session_invalidation` signal: backend closes the push stream + // once the device session flips to revoked. + if (!signal.aborted && session.status === "authenticated") { + await session.signOut("revoked"); + } +} + +const ConnectErrorCode = { + Canceled: 1, + Unauthenticated: 16, +} as const; + +function connectErrorCode(err: unknown): number | null { + if (err instanceof ConnectError) { + return err.code; + } + return null; +} + +async function sha256(payload: Uint8Array): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + payload as BufferSource, + ); + return new Uint8Array(digest); +} + +function fallbackRequestId(): string { + const buf = new Uint8Array(16); + crypto.getRandomValues(buf); + let hex = ""; + for (let i = 0; i < buf.length; i++) { + hex += buf[i]!.toString(16).padStart(2, "0"); + } + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} diff --git a/ui/frontend/src/lib/session-store.svelte.ts b/ui/frontend/src/lib/session-store.svelte.ts new file mode 100644 index 0000000..db264e4 --- /dev/null +++ b/ui/frontend/src/lib/session-store.svelte.ts @@ -0,0 +1,173 @@ +// `SessionStore` is the single source of truth for the device +// session state across every authenticated UI surface. It owns the +// lifecycle of the WebCrypto keypair (loaded or generated through +// the `KeyStore`), the persisted `device_session_id` (read/written +// through the `Cache`), and the high-level status that the root +// layout uses to gate routing. +// +// The store runs in two stages: `init()` loads the persisted +// keypair and session id, sanity-checks WebCrypto Ed25519 support, +// and settles `status` into one of `unsupported`, `anonymous`, or +// `authenticated`. Callers (the login form, the lobby) drive the +// rest through `signIn` and `signOut`. +// +// `signOut("revoked")` is a separate code path because gateway-side +// session revocation closes the SubscribeEvents stream +// asynchronously; the watcher in `lib/revocation-watcher.ts` calls +// it without user interaction. The post-condition is the same as +// `signOut("user")` — keypair regenerated, session id wiped, +// status returned to `anonymous` — so the layout's existing +// `anonymous → /login` redirect handles both reasons uniformly. + +import type { + Cache, + DeviceKeypair, + KeyStore, + StoreLoader, +} from "../platform/store/index"; +import { loadStore } from "../platform/store/index"; +import { + clearDeviceSession, + loadDeviceSession, + setDeviceSessionId, +} from "../api/session"; + +export type SessionStatus = + | "loading" + | "unsupported" + | "anonymous" + | "authenticated"; + +export class SessionStore { + status: SessionStatus = $state("loading"); + keypair: DeviceKeypair | null = $state(null); + deviceSessionId: string | null = $state(null); + + private initPromise: Promise | null = null; + private keyStore: KeyStore | null = null; + private cache: Cache | null = null; + private supportProbe: () => Promise = defaultSupportProbe; + private storeLoader: StoreLoader = loadStore; + + /** + * init loads the persisted keypair and device-session id, runs a + * one-time WebCrypto Ed25519 sanity check, and settles `status`. + * Calling it multiple times is safe — the first call drives the + * actual work; subsequent calls await the same promise. + */ + init(): Promise { + if (this.initPromise === null) { + this.initPromise = this.doInit(); + } + return this.initPromise; + } + + /** + * signIn persists the device-session id returned by the + * confirm-email-code response and flips the status to + * `authenticated`. The keypair already lives in the store from + * `init()`. + */ + async signIn(deviceSessionId: string): Promise { + if (this.cache === null) { + throw new Error("session store: signIn called before init"); + } + await setDeviceSessionId(this.cache, deviceSessionId); + this.deviceSessionId = deviceSessionId; + this.status = "authenticated"; + } + + /** + * signOut wipes the keypair and the persisted device-session id, + * generates a fresh keypair so the next login does not reuse the + * revoked public key, and returns the status to `anonymous`. The + * `reason` is recorded in console output for telemetry but does + * not change the post-state — both user-driven logout and + * gateway-driven revocation land the user back on `/login`. + */ + async signOut(reason: "user" | "revoked"): Promise { + if (this.keyStore === null || this.cache === null) { + throw new Error("session store: signOut called before init"); + } + await clearDeviceSession(this.keyStore, this.cache); + const fresh = await loadDeviceSession(this.keyStore, this.cache); + this.keypair = fresh.keypair; + this.deviceSessionId = null; + this.status = "anonymous"; + if (reason === "revoked") { + console.info("session store: device session revoked by gateway"); + } + } + + /** + * setSupportProbeForTests overrides the WebCrypto Ed25519 probe. + * Production code calls the real `crypto.subtle.generateKey`; tests + * can swap in a deterministic stub. + */ + setSupportProbeForTests(probe: () => Promise): void { + this.supportProbe = probe; + } + + /** + * setStoreLoaderForTests overrides the storage adapter resolver. + * Production code calls `loadStore()` from `platform/store`; tests + * inject a per-test `KeyStore` + `Cache` pair backed by a unique + * IndexedDB name so cases stay independent. + */ + setStoreLoaderForTests(loader: StoreLoader): void { + this.storeLoader = loader; + } + + /** + * resetForTests forgets all persisted state on the instance so a + * subsequent `init()` runs from scratch. Production code never + * calls this; it exists only for the Vitest harness. + */ + resetForTests(): void { + this.status = "loading"; + this.keypair = null; + this.deviceSessionId = null; + this.initPromise = null; + this.keyStore = null; + this.cache = null; + this.supportProbe = defaultSupportProbe; + this.storeLoader = loadStore; + } + + private async doInit(): Promise { + const supported = await this.supportProbe(); + if (!supported) { + this.status = "unsupported"; + return; + } + const { keyStore, cache } = await this.storeLoader(); + this.keyStore = keyStore; + this.cache = cache; + const loaded = await loadDeviceSession(keyStore, cache); + this.keypair = loaded.keypair; + this.deviceSessionId = loaded.deviceSessionId; + this.status = + loaded.deviceSessionId === null ? "anonymous" : "authenticated"; + } +} + +async function defaultSupportProbe(): Promise { + if ( + typeof globalThis.crypto !== "object" || + typeof globalThis.crypto.subtle !== "object" + ) { + return false; + } + try { + await globalThis.crypto.subtle.generateKey( + { name: "Ed25519" } as KeyAlgorithm, + false, + ["sign", "verify"], + ); + return true; + } catch { + return false; + } +} + +export const session = new SessionStore(); diff --git a/ui/frontend/src/routes/+layout.svelte b/ui/frontend/src/routes/+layout.svelte index a54cfdc..90a7eba 100644 --- a/ui/frontend/src/routes/+layout.svelte +++ b/ui/frontend/src/routes/+layout.svelte @@ -1,5 +1,70 @@ -{@render children()} +{#if session.status === "loading"} +
+

loading…

+
+{:else if session.status === "unsupported"} +
+

browser not supported

+

+ Galaxy requires Ed25519 in WebCrypto. The minimum supported browser + versions are listed in the + storage topic doc. +

+
+{:else} + {@render children()} +{/if} + + diff --git a/ui/frontend/src/routes/+layout.ts b/ui/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..ac220f9 --- /dev/null +++ b/ui/frontend/src/routes/+layout.ts @@ -0,0 +1,7 @@ +// SPA mode: every route is rendered client-side, no SSR or +// prerendering. The static adapter serves `fallback: "index.html"` and +// the layout-level session bootstrap drives the rest of the app from +// the browser only. + +export const ssr = false; +export const prerender = false; diff --git a/ui/frontend/src/routes/lobby/+page.svelte b/ui/frontend/src/routes/lobby/+page.svelte new file mode 100644 index 0000000..990b003 --- /dev/null +++ b/ui/frontend/src/routes/lobby/+page.svelte @@ -0,0 +1,96 @@ + + +
+

you are logged in

+

+ device session id: {session.deviceSessionId ?? ""} +

+ {#if accountLoading} +

loading account…

+ {:else if displayName !== null} +

+ hello, {displayName}! +

+ {:else if accountError !== null} +

{accountError}

+ {/if} + +
+ + diff --git a/ui/frontend/src/routes/lobby/+page.ts b/ui/frontend/src/routes/lobby/+page.ts new file mode 100644 index 0000000..a8c49d6 --- /dev/null +++ b/ui/frontend/src/routes/lobby/+page.ts @@ -0,0 +1,6 @@ +// Lobby is the first authenticated screen and depends on the +// session keypair plus the WASM core loaded at runtime; SSR and +// prerendering stay disabled. + +export const ssr = false; +export const prerender = false; diff --git a/ui/frontend/src/routes/login/+page.svelte b/ui/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..319d2f9 --- /dev/null +++ b/ui/frontend/src/routes/login/+page.svelte @@ -0,0 +1,237 @@ + + +
+

sign in to Galaxy

+ + {#if step === "email"} +
+ + +
+ {:else} +
+

code sent to {email}

+ + +
+ + +
+
+ {/if} + + {#if error !== null} +

{error}

+ {/if} +
+ + diff --git a/ui/frontend/src/routes/login/+page.ts b/ui/frontend/src/routes/login/+page.ts new file mode 100644 index 0000000..b012416 --- /dev/null +++ b/ui/frontend/src/routes/login/+page.ts @@ -0,0 +1,6 @@ +// Login depends on browser-only WebCrypto and IndexedDB through the +// session store; SSR and prerendering are disabled to keep the +// component out of the server-render pipeline. + +export const ssr = false; +export const prerender = false; diff --git a/ui/frontend/tests/auth-api.test.ts b/ui/frontend/tests/auth-api.test.ts new file mode 100644 index 0000000..3528aad --- /dev/null +++ b/ui/frontend/tests/auth-api.test.ts @@ -0,0 +1,145 @@ +// Verifies the wire shape of `sendEmailCode` / `confirmEmailCode` +// against the gateway public auth REST surface defined in +// `backend/openapi.yaml`. The transport is mocked through +// `globalThis.fetch`; the helpers themselves are exercised in the +// e2e Playwright spec against a Connect-Web mock that adds back the +// real network stack (still not a live gateway). + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { AuthError, confirmEmailCode, sendEmailCode } from "../src/api/auth"; + +const BASE_URL = "https://gateway.test"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("sendEmailCode", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("posts a JSON body with the email and returns the challenge id", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse(200, { challenge_id: "ch-123" }), + ); + + const result = await sendEmailCode(BASE_URL, "pilot@example.com"); + + expect(result).toEqual({ challengeId: "ch-123" }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe(`${BASE_URL}/api/v1/public/auth/send-email-code`); + expect(init?.method).toBe("POST"); + expect(init?.headers).toEqual({ "content-type": "application/json" }); + expect(JSON.parse(init?.body as string)).toEqual({ + email: "pilot@example.com", + }); + }); + + test("strips a trailing slash from the base URL", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(200, { challenge_id: "ch-1" })); + await sendEmailCode(`${BASE_URL}/`, "pilot@example.com"); + expect(fetchSpy.mock.calls[0]![0]).toBe( + `${BASE_URL}/api/v1/public/auth/send-email-code`, + ); + }); + + test("throws AuthError carrying gateway code and message on 400", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse(400, { + error: { code: "invalid_request", message: "email must be valid" }, + }), + ); + + await expect(sendEmailCode(BASE_URL, "bad")).rejects.toMatchObject({ + name: "AuthError", + code: "invalid_request", + message: "email must be valid", + status: 400, + }); + }); + + test("falls back to internal_error on a 5xx without an envelope", async () => { + fetchSpy.mockResolvedValueOnce( + new Response("oops", { status: 503 }), + ); + + const err = await sendEmailCode(BASE_URL, "pilot@example.com").catch( + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(AuthError); + expect((err as AuthError).code).toBe("internal_error"); + expect((err as AuthError).status).toBe(503); + }); + + test("throws on a malformed success body", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(200, { challenge_id: 42 })); + await expect(sendEmailCode(BASE_URL, "p@x")).rejects.toBeInstanceOf( + AuthError, + ); + }); +}); + +describe("confirmEmailCode", () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as unknown as typeof fetch; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("base64-encodes the public key and forwards the time zone", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse(200, { device_session_id: "dev-uuid-1" }), + ); + + const publicKey = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + const result = await confirmEmailCode(BASE_URL, { + challengeId: "ch-1", + code: "123456", + publicKey, + timeZone: "Europe/Berlin", + }); + + expect(result).toEqual({ deviceSessionId: "dev-uuid-1" }); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe(`${BASE_URL}/api/v1/public/auth/confirm-email-code`); + const body = JSON.parse(init?.body as string) as Record; + expect(body.challenge_id).toBe("ch-1"); + expect(body.code).toBe("123456"); + expect(body.time_zone).toBe("Europe/Berlin"); + expect(body.client_public_key).toBe(btoa("\xde\xad\xbe\xef")); + }); + + test("AuthError on 400 carries the gateway error code", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse(400, { + error: { code: "invalid_request", message: "bad code" }, + }), + ); + + await expect( + confirmEmailCode(BASE_URL, { + challengeId: "ch", + code: "wrong", + publicKey: new Uint8Array(32), + timeZone: "UTC", + }), + ).rejects.toMatchObject({ code: "invalid_request", status: 400 }); + }); +}); diff --git a/ui/frontend/tests/e2e/auth-flow.spec.ts b/ui/frontend/tests/e2e/auth-flow.spec.ts new file mode 100644 index 0000000..a4ca3c9 --- /dev/null +++ b/ui/frontend/tests/e2e/auth-flow.spec.ts @@ -0,0 +1,220 @@ +// Phase 7 end-to-end coverage for the email-code login flow, +// returning-user resume, gateway-driven session revocation, and the +// browser-not-supported blocker. The gateway is mocked through +// `page.route(...)`; the lobby's first authenticated `user.account.get` +// call is answered with a forged `ExecuteCommandResponse` signed by +// the fixture key in `fixtures/gateway-key.ts` so `GalaxyClient. +// verifyResponse` accepts it under the matching public key the dev +// server picks up via `VITE_GATEWAY_RESPONSE_PUBLIC_KEY`. +// +// The Connect-Web request URL pattern is +// /galaxy.gateway.v1.EdgeGateway/ +// so the route handlers below match against the trailing path +// suffix and ignore the host. + +import { fromJson, type JsonValue } from "@bufbuild/protobuf"; +import { expect, test, type Page } from "@playwright/test"; +import { ExecuteCommandRequestSchema } from "../../src/proto/galaxy/gateway/v1/edge_gateway_pb"; +import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response"; + +interface MockSetup { + pendingSubscribes: Array<() => void>; +} + +async function mockGatewayHappyPath( + page: Page, + displayName: string, +): Promise { + const pendingSubscribes: Array<() => void> = []; + + await page.route("**/api/v1/public/auth/send-email-code", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ challenge_id: "ch-test-1" }), + }); + }); + + await page.route( + "**/api/v1/public/auth/confirm-email-code", + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ device_session_id: "dev-test-1" }), + }); + }, + ); + + await page.route( + "**/galaxy.gateway.v1.EdgeGateway/ExecuteCommand", + async (route) => { + const reqText = route.request().postData(); + if (reqText === null) { + await route.fulfill({ status: 400 }); + return; + } + const req = fromJson( + ExecuteCommandRequestSchema, + JSON.parse(reqText) as JsonValue, + ); + const accountJson = JSON.stringify({ + account: { + user_id: "user-1", + email: "pilot@example.com", + user_name: "player-test", + display_name: displayName, + }, + }); + const responseJson = await forgeExecuteCommandResponseJson({ + requestId: req.requestId, + timestampMs: BigInt(Date.now()), + resultCode: "ok", + payloadBytes: new TextEncoder().encode(accountJson), + }); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: responseJson, + }); + }, + ); + + await page.route( + "**/galaxy.gateway.v1.EdgeGateway/SubscribeEvents", + async (route) => { + // Hold the stream open until the test releases it via + // `pendingSubscribes`. Releasing fulfils with a Connect + // end-of-stream frame so the watcher reads a clean stream + // end and triggers the documented revocation path. + const action = await new Promise<"endOfStream" | "abort">( + (resolve) => { + pendingSubscribes.push(() => resolve("endOfStream")); + }, + ); + if (action === "abort") { + await route.abort(); + return; + } + // Connect over HTTP/1.1 server-streaming framing: + // 1 byte flag (0x02 = end-stream) + // 4 bytes big-endian length + // N bytes JSON body + // `{}` is an end-stream with no error; that's how the + // gateway closes a healthy stream after a session + // invalidation. + const body = new TextEncoder().encode("{}"); + const frame = new Uint8Array(5 + body.length); + frame[0] = 0x02; + new DataView(frame.buffer).setUint32(1, body.length, false); + frame.set(body, 5); + await route.fulfill({ + status: 200, + contentType: "application/connect+json", + body: Buffer.from(frame), + }); + }, + ); + + return { pendingSubscribes }; +} + +async function completeLogin(page: Page): Promise { + await page.goto("/"); + await expect(page).toHaveURL(/\/login$/); + await page.getByTestId("login-email-input").fill("pilot@example.com"); + await page.getByTestId("login-email-submit").click(); + await expect(page.getByTestId("login-code-input")).toBeVisible(); + await page.getByTestId("login-code-input").fill("123456"); + await page.getByTestId("login-code-submit").click(); + await expect(page).toHaveURL(/\/lobby$/); +} + +test.describe("Phase 7 — auth flow", () => { + // Each test runs in a fresh browser context by default, so IndexedDB + // starts empty on every test entry. The setup hook here is reserved + // for routes shared across cases. + + test("fresh browser completes the email-code login and reaches the lobby", async ({ + page, + }) => { + const mocks = await mockGatewayHappyPath(page, "Pilot"); + await completeLogin(page); + await expect(page.getByTestId("device-session-id")).toHaveText( + "dev-test-1", + ); + await expect(page.getByTestId("account-display-name")).toHaveText("Pilot"); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("returning user lands on the lobby without re-login", async ({ + page, + }) => { + const mocks = await mockGatewayHappyPath(page, "Pilot"); + await completeLogin(page); + await expect(page.getByTestId("account-display-name")).toBeVisible(); + + await page.reload(); + await expect(page).toHaveURL(/\/lobby$/); + await expect(page.getByTestId("device-session-id")).toHaveText( + "dev-test-1", + ); + + mocks.pendingSubscribes.forEach((resolve) => resolve()); + }); + + test("server-side revocation closes the active client within one second", async ({ + page, + }) => { + const mocks = await mockGatewayHappyPath(page, "Pilot"); + await completeLogin(page); + await expect(page.getByTestId("account-display-name")).toBeVisible(); + + // Fire all pending SubscribeEvents requests with an empty 200 + // response. Connect-Web's server-streaming reader sees no frames + // and the watcher trips into `signOut("revoked")`, which the + // layout effect turns into a redirect back to /login. + const releaseAt = Date.now(); + mocks.pendingSubscribes.forEach((resolve) => resolve()); + + await expect(page).toHaveURL(/\/login$/, { timeout: 1000 }); + expect(Date.now() - releaseAt).toBeLessThan(1500); + }); + + test("browser without WebCrypto Ed25519 shows the not-supported blocker", async ({ + page, + }) => { + await page.addInitScript(() => { + // Force the SessionStore probe to fail by making + // generateKey reject for Ed25519 specifically. Other + // algorithms continue to work so unrelated browser code + // is not disturbed. + const original = crypto.subtle.generateKey.bind(crypto.subtle); + crypto.subtle.generateKey = (( + algorithm: AlgorithmIdentifier | RsaHashedKeyGenParams | EcKeyGenParams, + extractable: boolean, + keyUsages: KeyUsage[], + ) => { + const name = + typeof algorithm === "string" + ? algorithm + : (algorithm as KeyAlgorithm).name; + if (typeof name === "string" && name.toLowerCase() === "ed25519") { + return Promise.reject( + new DOMException("not supported", "NotSupportedError"), + ); + } + return original( + algorithm as Parameters[0], + extractable, + keyUsages, + ); + }) as typeof crypto.subtle.generateKey; + }); + + await page.goto("/"); + await expect(page.getByText(/browser not supported/i)).toBeVisible(); + await expect(page).not.toHaveURL(/\/login$/); + }); +}); diff --git a/ui/frontend/tests/e2e/fixtures/canon.ts b/ui/frontend/tests/e2e/fixtures/canon.ts new file mode 100644 index 0000000..1402326 --- /dev/null +++ b/ui/frontend/tests/e2e/fixtures/canon.ts @@ -0,0 +1,57 @@ +// TypeScript port of the canonical response-signing serializer in +// `ui/core/canon/response.go` (`BuildResponseSigningInput`). Used by +// the Phase 7 Playwright spec to forge gateway responses and sign +// them with the fixture key. The Go-side parity check +// (`gateway/authn/parity_with_ui_core_test.go`) is the source of +// truth; this TS copy stays small enough to read against that test. + +const RESPONSE_DOMAIN_MARKER_V1 = "galaxy-response-v1"; + +export interface ResponseSigningFields { + protocolVersion: string; + requestId: string; + timestampMs: bigint; + resultCode: string; + payloadHash: Uint8Array; +} + +export function buildResponseSigningInput( + fields: ResponseSigningFields, +): Uint8Array { + const parts: number[] = []; + appendLengthPrefixedString(parts, RESPONSE_DOMAIN_MARKER_V1); + appendLengthPrefixedString(parts, fields.protocolVersion); + appendLengthPrefixedString(parts, fields.requestId); + appendBigEndianUint64(parts, fields.timestampMs); + appendLengthPrefixedString(parts, fields.resultCode); + appendLengthPrefixedBytes(parts, fields.payloadHash); + return new Uint8Array(parts); +} + +function appendLengthPrefixedString(dst: number[], value: string): void { + const bytes = new TextEncoder().encode(value); + appendLengthPrefixedBytes(dst, bytes); +} + +function appendLengthPrefixedBytes(dst: number[], value: Uint8Array): void { + appendUvarint(dst, BigInt(value.length)); + for (let i = 0; i < value.length; i++) { + dst.push(value[i]!); + } +} + +function appendUvarint(dst: number[], value: bigint): void { + let v = value; + while (v >= 0x80n) { + dst.push(Number(v & 0xffn) | 0x80); + v >>= 7n; + } + dst.push(Number(v & 0xffn)); +} + +function appendBigEndianUint64(dst: number[], value: bigint): void { + const v = value & 0xffffffffffffffffn; + for (let i = 7; i >= 0; i--) { + dst.push(Number((v >> BigInt(i * 8)) & 0xffn)); + } +} diff --git a/ui/frontend/tests/e2e/fixtures/gateway-key.ts b/ui/frontend/tests/e2e/fixtures/gateway-key.ts new file mode 100644 index 0000000..d1da5a9 --- /dev/null +++ b/ui/frontend/tests/e2e/fixtures/gateway-key.ts @@ -0,0 +1,17 @@ +// Deterministic Ed25519 keypair used by the Phase 7 Playwright e2e +// suite to forge gateway-shaped responses inside `page.route(...)`. +// The pair was generated once with Node's WebCrypto and is checked +// in: it is purely test fixture material, not used in production +// builds, and the public half lands in the dev server via +// `VITE_GATEWAY_RESPONSE_PUBLIC_KEY` from `playwright.config.ts`. + +export const FIXTURE_PUBLIC_KEY_RAW_BASE64 = + "3Jf1C+qApVeysTytS6umsvTGqNfn3oHcagJhO97Ias4="; + +export const FIXTURE_PRIVATE_KEY_PKCS8_BASE64 = + "MC4CAQAwBQYDK2VwBCIEIGnpfNAYxKJivan1ww5uvidgozuz9JXQM9dcdYrSiHHt"; + +export function decodeBase64(value: string): Uint8Array { + const bin = Buffer.from(value, "base64"); + return new Uint8Array(bin.buffer, bin.byteOffset, bin.byteLength); +} diff --git a/ui/frontend/tests/e2e/fixtures/sign-response.ts b/ui/frontend/tests/e2e/fixtures/sign-response.ts new file mode 100644 index 0000000..360eb88 --- /dev/null +++ b/ui/frontend/tests/e2e/fixtures/sign-response.ts @@ -0,0 +1,83 @@ +// Helper used by `auth-flow.spec.ts` to forge a Connect-Web-shaped +// `ExecuteCommandResponse` signed with the fixture gateway response +// key. Lives next to the keypair fixture so the e2e file stays +// focused on the UI flow. Connect-Web's default transport uses +// JSON over HTTP/1.1, so the helper emits JSON bytes; the canonical +// signing input is still the binary form defined in +// `ui/core/canon/response.go`. + +import { create, toJson, toJsonString } from "@bufbuild/protobuf"; +import { webcrypto } from "node:crypto"; +import { ExecuteCommandResponseSchema } from "../../../src/proto/galaxy/gateway/v1/edge_gateway_pb"; +import { + FIXTURE_PRIVATE_KEY_PKCS8_BASE64, + decodeBase64, +} from "./gateway-key"; +import { buildResponseSigningInput } from "./canon"; + +const PROTOCOL_VERSION = "v1"; + +export interface ForgedResponseInput { + requestId: string; + timestampMs: bigint; + resultCode: string; + payloadBytes: Uint8Array; +} + +let cachedPrivateKey: CryptoKey | null = null; + +async function privateKey(): Promise { + if (cachedPrivateKey !== null) { + return cachedPrivateKey; + } + const pkcs8 = decodeBase64(FIXTURE_PRIVATE_KEY_PKCS8_BASE64); + cachedPrivateKey = await webcrypto.subtle.importKey( + "pkcs8", + pkcs8, + { name: "Ed25519" }, + false, + ["sign"], + ); + return cachedPrivateKey; +} + +async function sha256(payload: Uint8Array): Promise { + const digest = await webcrypto.subtle.digest("SHA-256", payload); + return new Uint8Array(digest); +} + +/** + * forgeExecuteCommandResponseJson produces the JSON body of a + * gateway response that `GalaxyClient.executeCommand` will accept + * under the fixture public key, encoded the way Connect-Web's + * default JSON transport expects to receive it. + */ +export async function forgeExecuteCommandResponseJson( + input: ForgedResponseInput, +): Promise { + const payloadHash = await sha256(input.payloadBytes); + const canonical = buildResponseSigningInput({ + protocolVersion: PROTOCOL_VERSION, + requestId: input.requestId, + timestampMs: input.timestampMs, + resultCode: input.resultCode, + payloadHash, + }); + const sig = await webcrypto.subtle.sign( + { name: "Ed25519" }, + await privateKey(), + canonical, + ); + const message = create(ExecuteCommandResponseSchema, { + protocolVersion: PROTOCOL_VERSION, + requestId: input.requestId, + timestampMs: input.timestampMs, + resultCode: input.resultCode, + payloadBytes: input.payloadBytes, + payloadHash, + signature: new Uint8Array(sig), + }); + return toJsonString(ExecuteCommandResponseSchema, message); +} + +export { toJson }; diff --git a/ui/frontend/tests/e2e/landing.spec.ts b/ui/frontend/tests/e2e/landing.spec.ts deleted file mode 100644 index dece0ed..0000000 --- a/ui/frontend/tests/e2e/landing.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("landing page renders the version string", async ({ page }) => { - await page.goto("/"); - const footer = page.getByTestId("app-version"); - await expect(footer).toBeVisible(); - await expect(footer).toContainText(/version\s+\S+/); -}); diff --git a/ui/frontend/tests/login-page.test.ts b/ui/frontend/tests/login-page.test.ts new file mode 100644 index 0000000..05f652c --- /dev/null +++ b/ui/frontend/tests/login-page.test.ts @@ -0,0 +1,225 @@ +// Login page component tests. The `auth` API and the navigation +// helper are mocked at module level; the session singleton is wired +// to a per-test `SessionStore`-backing IndexedDB so the keypair the +// form passes to `confirmEmailCode` is a genuine 32-byte Ed25519 +// public key without polluting the production `dbConnection()` +// cache. + +import "fake-indexeddb/auto"; +import { fireEvent, render, waitFor } from "@testing-library/svelte"; +import { + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import type { IDBPDatabase } from "idb"; + +import { AuthError } from "../src/api/auth"; +import { session } from "../src/lib/session-store.svelte"; +import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb"; +import { IDBCache } from "../src/platform/store/idb-cache"; +import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; + +vi.mock("$app/navigation", () => ({ + goto: vi.fn(async () => {}), +})); + +const sendEmailCodeSpy = vi.fn(); +const confirmEmailCodeSpy = vi.fn(); + +vi.mock("../src/api/auth", async () => { + const actual = await vi.importActual( + "../src/api/auth", + ); + return { + ...actual, + sendEmailCode: (...args: unknown[]) => sendEmailCodeSpy(...args), + confirmEmailCode: (...args: unknown[]) => confirmEmailCodeSpy(...args), + }; +}); + +let db: IDBPDatabase; +let dbName: string; + +beforeEach(async () => { + dbName = `galaxy-ui-test-${crypto.randomUUID()}`; + db = await openGalaxyDB(dbName); + const store = { + keyStore: new WebCryptoKeyStore(db), + cache: new IDBCache(db), + }; + session.resetForTests(); + session.setStoreLoaderForTests(async () => store); + await session.init(); + sendEmailCodeSpy.mockReset(); + confirmEmailCodeSpy.mockReset(); +}); + +afterEach(async () => { + sendEmailCodeSpy.mockReset(); + confirmEmailCodeSpy.mockReset(); + session.resetForTests(); + db.close(); + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +}); + +async function importLoginPage(): Promise { + return import("../src/routes/login/+page.svelte"); +} + +describe("login page", () => { + test("submitting the email step calls sendEmailCode and advances to step=code", async () => { + sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); + const Page = (await importLoginPage()).default; + const ui = render(Page); + + const emailInput = ui.getByTestId("login-email-input") as HTMLInputElement; + await fireEvent.input(emailInput, { + target: { value: "pilot@example.com" }, + }); + await fireEvent.click(ui.getByTestId("login-email-submit")); + + await waitFor(() => { + expect(sendEmailCodeSpy).toHaveBeenCalledWith( + expect.any(String), + "pilot@example.com", + ); + expect(ui.getByTestId("login-code-input")).toBeInTheDocument(); + }); + }); + + test("a send-email-code error stays on the email step and surfaces the message", async () => { + sendEmailCodeSpy.mockRejectedValueOnce( + new AuthError("service_unavailable", "auth service is unavailable", 503), + ); + const Page = (await importLoginPage()).default; + const ui = render(Page); + + // Use a syntactically valid e-mail so JSDOM does not block form + // submission via the `type="email"` constraint; the gateway is + // expected to reject the request with `service_unavailable` + // regardless of the address shape. + await fireEvent.input(ui.getByTestId("login-email-input"), { + target: { value: "pilot@example.com" }, + }); + await fireEvent.click(ui.getByTestId("login-email-submit")); + + await waitFor(() => { + expect(ui.getByTestId("login-error")).toHaveTextContent( + "auth service is unavailable", + ); + }); + expect(ui.queryByTestId("login-code-input")).toBeNull(); + }); + + test("submitting the code step calls confirmEmailCode and signs the user in", async () => { + sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); + confirmEmailCodeSpy.mockResolvedValueOnce({ deviceSessionId: "dev-1" }); + const Page = (await importLoginPage()).default; + const ui = render(Page); + + await fireEvent.input(ui.getByTestId("login-email-input"), { + target: { value: "pilot@example.com" }, + }); + await fireEvent.click(ui.getByTestId("login-email-submit")); + await waitFor(() => ui.getByTestId("login-code-input")); + + await fireEvent.input(ui.getByTestId("login-code-input"), { + target: { value: "123456" }, + }); + await fireEvent.click(ui.getByTestId("login-code-submit")); + + await waitFor(() => { + expect(confirmEmailCodeSpy).toHaveBeenCalledTimes(1); + expect(session.deviceSessionId).toBe("dev-1"); + expect(session.status).toBe("authenticated"); + }); + const args = confirmEmailCodeSpy.mock.calls[0]![1]!; + expect(args.challengeId).toBe("ch-1"); + expect(args.code).toBe("123456"); + expect(args.publicKey).toBeInstanceOf(Uint8Array); + expect(args.publicKey.length).toBe(32); + expect(typeof args.timeZone).toBe("string"); + }); + + test("a confirm-email-code invalid_request bounces back to step=email with an error", async () => { + sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); + confirmEmailCodeSpy.mockRejectedValueOnce( + new AuthError("invalid_request", "code expired", 400), + ); + const Page = (await importLoginPage()).default; + const ui = render(Page); + + await fireEvent.input(ui.getByTestId("login-email-input"), { + target: { value: "pilot@example.com" }, + }); + await fireEvent.click(ui.getByTestId("login-email-submit")); + await waitFor(() => ui.getByTestId("login-code-input")); + + await fireEvent.input(ui.getByTestId("login-code-input"), { + target: { value: "00000" }, + }); + await fireEvent.click(ui.getByTestId("login-code-submit")); + + await waitFor(() => { + expect(ui.queryByTestId("login-code-input")).toBeNull(); + expect(ui.getByTestId("login-email-input")).toBeInTheDocument(); + expect(ui.getByTestId("login-error")).toHaveTextContent( + /expired|already used/i, + ); + }); + }); + + test("resend re-issues sendEmailCode and clears the code field", async () => { + sendEmailCodeSpy + .mockResolvedValueOnce({ challengeId: "ch-1" }) + .mockResolvedValueOnce({ challengeId: "ch-2" }); + const Page = (await importLoginPage()).default; + const ui = render(Page); + + await fireEvent.input(ui.getByTestId("login-email-input"), { + target: { value: "pilot@example.com" }, + }); + await fireEvent.click(ui.getByTestId("login-email-submit")); + await waitFor(() => ui.getByTestId("login-code-input")); + + await fireEvent.input(ui.getByTestId("login-code-input"), { + target: { value: "999999" }, + }); + await fireEvent.click(ui.getByTestId("login-resend")); + + await waitFor(() => { + expect(sendEmailCodeSpy).toHaveBeenCalledTimes(2); + expect( + (ui.getByTestId("login-code-input") as HTMLInputElement).value, + ).toBe(""); + }); + }); + + test("change-email returns to the email step", async () => { + sendEmailCodeSpy.mockResolvedValueOnce({ challengeId: "ch-1" }); + const Page = (await importLoginPage()).default; + const ui = render(Page); + + await fireEvent.input(ui.getByTestId("login-email-input"), { + target: { value: "pilot@example.com" }, + }); + await fireEvent.click(ui.getByTestId("login-email-submit")); + await waitFor(() => ui.getByTestId("login-code-input")); + + await fireEvent.click(ui.getByTestId("login-change-email")); + + await waitFor(() => { + expect(ui.queryByTestId("login-code-input")).toBeNull(); + expect(ui.getByTestId("login-email-input")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/frontend/tests/session-store.test.ts b/ui/frontend/tests/session-store.test.ts new file mode 100644 index 0000000..fe63d3e --- /dev/null +++ b/ui/frontend/tests/session-store.test.ts @@ -0,0 +1,129 @@ +// SessionStore unit tests under JSDOM with `fake-indexeddb` and Node +// 22's WebCrypto. Each case wires a fresh `SessionStore` against a +// per-test IndexedDB name, so persistence behaviour is observable +// across cases without bleed and without touching the production +// `dbConnection()` cache. + +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import type { IDBPDatabase } from "idb"; +import { type GalaxyDB, openGalaxyDB } from "../src/platform/store/idb"; +import { IDBCache } from "../src/platform/store/idb-cache"; +import { WebCryptoKeyStore } from "../src/platform/store/webcrypto-keystore"; +import type { Store } from "../src/platform/store/index"; +import { SessionStore } from "../src/lib/session-store.svelte"; + +let db: IDBPDatabase; +let dbName: string; +let store: Store; + +beforeEach(async () => { + dbName = `galaxy-ui-test-${crypto.randomUUID()}`; + db = await openGalaxyDB(dbName); + store = { + keyStore: new WebCryptoKeyStore(db), + cache: new IDBCache(db), + }; +}); + +afterEach(async () => { + db.close(); + await new Promise((resolve) => { + const req = indexedDB.deleteDatabase(dbName); + req.onsuccess = () => resolve(); + req.onerror = () => resolve(); + req.onblocked = () => resolve(); + }); +}); + +function newSessionStore(): SessionStore { + const s = new SessionStore(); + s.setStoreLoaderForTests(async () => store); + return s; +} + +describe("SessionStore.init", () => { + test("settles to anonymous when no device-session id is persisted", async () => { + const session = newSessionStore(); + await session.init(); + expect(session.status).toBe("anonymous"); + expect(session.deviceSessionId).toBeNull(); + expect(session.keypair).not.toBeNull(); + expect(session.keypair!.publicKey.length).toBe(32); + }); + + test("settles to authenticated when a device-session id is persisted", async () => { + const first = newSessionStore(); + await first.init(); + await first.signIn("dev-1"); + expect(first.status).toBe("authenticated"); + + // Simulate a fresh page load: a new SessionStore against the + // same IndexedDB picks up the previously persisted session. + const second = newSessionStore(); + await second.init(); + expect(second.status).toBe("authenticated"); + expect(second.deviceSessionId).toBe("dev-1"); + expect(second.keypair).not.toBeNull(); + }); + + test("flips status to unsupported when WebCrypto Ed25519 is missing", async () => { + const session = newSessionStore(); + session.setSupportProbeForTests(async () => false); + await session.init(); + expect(session.status).toBe("unsupported"); + expect(session.keypair).toBeNull(); + }); + + test("init is idempotent", async () => { + const session = newSessionStore(); + await Promise.all([session.init(), session.init(), session.init()]); + expect(session.status).toBe("anonymous"); + }); +}); + +describe("SessionStore.signIn / signOut", () => { + test("signIn persists the device-session id and updates status", async () => { + const session = newSessionStore(); + await session.init(); + await session.signIn("dev-2"); + expect(session.deviceSessionId).toBe("dev-2"); + expect(session.status).toBe("authenticated"); + + const reload = newSessionStore(); + await reload.init(); + expect(reload.deviceSessionId).toBe("dev-2"); + }); + + test("signOut('user') wipes id, regenerates keypair, returns to anonymous", async () => { + const session = newSessionStore(); + await session.init(); + const firstPublicKey = Array.from(session.keypair!.publicKey); + await session.signIn("dev-3"); + await session.signOut("user"); + expect(session.deviceSessionId).toBeNull(); + expect(session.status).toBe("anonymous"); + expect(session.keypair).not.toBeNull(); + const secondPublicKey = Array.from(session.keypair!.publicKey); + expect(secondPublicKey).not.toEqual(firstPublicKey); + }); + + test("signOut('revoked') has the same observable post-state as 'user'", async () => { + const session = newSessionStore(); + await session.init(); + await session.signIn("dev-4"); + await session.signOut("revoked"); + expect(session.status).toBe("anonymous"); + expect(session.deviceSessionId).toBeNull(); + }); + + test("signIn before init throws", async () => { + const local = new SessionStore(); + await expect(local.signIn("x")).rejects.toThrow(/before init/); + }); + + test("signOut before init throws", async () => { + const local = new SessionStore(); + await expect(local.signOut("user")).rejects.toThrow(/before init/); + }); +});