diff --git a/README.md b/README.md index 93b72f1..ced7e96 100644 --- a/README.md +++ b/README.md @@ -941,8 +941,7 @@ The Dashboard follows the operating system's **Light or Dark** appearance and ** setting, live and without a reload, and so does every module's UI, through the same design tokens ([ADR 0060](docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The dark theme is designed rather than inverted, every text and focus pairing is contrast-tested to WCAG AA in both -schemes, corners scale from one `radius.scale` token, and an explicit Light or Dark choice is one call -away once account preferences exist. Displays stay light. +schemes, and corners scale from one `radius.scale` token. Displays stay light. **Settings** has its own place in the sidebar ([ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)): a @@ -952,6 +951,14 @@ and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring Settings page. A person sees only what the server says they may administer, and no Settings link at all when that is nothing. The pages are placeholders until each section is built. +**Your account** is at the bottom of the sidebar, showing your name, email, and initials +([ADR 0062](docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)). +`/account` shows who you are signed in as. On `/account/appearance` you choose **System, Light, or +Dark** and **reduced motion** (System, Reduce, or Allow). A choice takes effect at once, is saved to +your account so it follows you to every device, and is applied before the first paint on your next +visit. Managing your own appearance needs no administrative permission. Profile editing and an +avatar image are not part of this. + ## Quick start Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. @@ -1007,7 +1014,7 @@ Modules ✓ Dependency graph valid Dependency order is empty. ✓ Permission registry composed - 6 permissions registered + 7 permissions registered ○ Module lifecycle skipped DATABASE_URL is not configured. ○ Module configuration skipped @@ -1017,7 +1024,7 @@ Data definitions ✓ Schemas validated 0 modules · 0 tables ✓ Migrations validated - 7 migrations · checksums valid + 9 migrations · checksums valid core:0000_identity_core_tables · f8f2086ef06f core:0001_authorization_core_tables · 830e1fbc26d0 core:0002_authorization_integrity · 634294731180 @@ -1025,6 +1032,8 @@ Data definitions core:0004_module_lifecycle_state · c8b5d8b3d48b core:0005_module_configuration · c8d57f24f84b core:0006_device_identity · 1db97b7e3ac5 + core:0007_verified_bootstrap_owners · 49013062f3e0 + core:0008_user_preferences · fa9d4b0bb9d3 PostgreSQL ✓ Database TLS verified diff --git a/apps/api/package.json b/apps/api/package.json index 45f3569..0762365 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@biomejs/biome": "2.5.3", + "@ekklesia/design-tokens": "workspace:*", "@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/typescript-config": "workspace:*", diff --git a/apps/api/src/account-routes.ts b/apps/api/src/account-routes.ts new file mode 100644 index 0000000..f8c4c33 --- /dev/null +++ b/apps/api/src/account-routes.ts @@ -0,0 +1,157 @@ +import { isAuthenticatedPrincipal } from "@ekklesia/contracts"; +import { + type AccountPreferences, + type AccountStore, + AccountStoreError, + parseAccountPreferencesUpdate, +} from "@ekklesia/core/account"; +import { CORE_ACCOUNT_SELF_MANAGE } from "@ekklesia/core/authorization"; +import { type Context, Hono } from "hono"; +import { + type AuthorizationMiddlewareOptions, + getAuthorizedPrincipal, + requirePermission, + requireTrustedOrigin, +} from "./authorization-middleware.js"; +import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; + +export const ACCOUNT_PATH = "/api/v1/account"; +export const ACCOUNT_PREFERENCES_PATH = `${ACCOUNT_PATH}/preferences`; + +/** `GET /api/v1/account`: the signed-in person's own account, as the account area shows it. */ +export interface AccountResponse { + readonly name: string; + readonly email: string; + readonly preferences: AccountPreferences; +} + +export interface CreateAccountRoutesOptions extends AuthorizationMiddlewareOptions { + readonly store: AccountStore; + readonly trustedOrigins: ReadonlySet; +} + +function sanitizedFailure(c: Context, status: 400 | 403 | 404 | 503, code: string): Response { + c.header("Cache-Control", "no-store"); + return c.json({ code }, status); +} + +/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. */ +function storeFailure( + c: Context, + error: unknown, + fallback: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED", +): Response { + if (error instanceof AccountStoreError) { + if (error.code === "ACCOUNT_NOT_FOUND") { + return sanitizedFailure(c, 404, error.code); + } + if (error.code === "ACCOUNT_PREFERENCES_INVALID") { + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + return sanitizedFailure(c, 503, error.code); + } + return sanitizedFailure(c, 503, fallback); +} + +async function readJsonBody(c: Context): Promise { + try { + return (await c.req.json()) as unknown; + } catch { + return undefined; + } +} + +/** + * A signed-in person's own account (ADR 0062): who they are signed in as, and their own + * preferences. + * + * ## Self-only by construction + * + * Neither route accepts a user id — not in the path, not in the query, not in the body. The account + * is always the one the **session** names, so there is no request that could read or change anyone + * else's, whatever permission the caller holds. A body that names any key besides a known + * preference is refused whole. + * + * ## Authorization + * + * Both routes require `core.account.self.manage`, whose baseline is `authenticated`: every + * signed-in person may manage their own account, and needs no church-management permission to do + * so. An anonymous caller and a paired device are refused (`403`). Changing a preference is a + * mutation like any other Core mutation: an exact trusted `Origin` and a bounded body are checked + * before authorization work begins. + * + * ## What the browser learns + * + * Name, email, and preferences — exactly what the account area shows, and nothing else. Not the + * account's image URL, verification state, timestamps, or any credential. Responses are per-person + * and never cached. + */ +export function createAccountRoutes(options: CreateAccountRoutesOptions): Hono { + const routes = new Hono(); + const requireSelf = requirePermission(options, CORE_ACCOUNT_SELF_MANAGE); + const requireOrigin = requireTrustedOrigin(options.trustedOrigins); + const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES); + + routes.get(ACCOUNT_PATH, requireSelf, async (c) => { + c.header("Cache-Control", "no-store"); + c.header("Vary", "Origin, Cookie"); + const principal = getAuthorizedPrincipal(c); + // Unreachable through the `authenticated` baseline, which only a person satisfies; checked + // again so the account read can never be keyed by anything but a person's own session. + if (!isAuthenticatedPrincipal(principal)) { + return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED"); + } + try { + const account = await options.store.readAccount(principal.userId); + if (!account) { + return sanitizedFailure(c, 404, "ACCOUNT_NOT_FOUND"); + } + return c.json( + { + email: account.email, + name: account.name, + preferences: { + appearance: account.preferences.appearance, + motion: account.preferences.motion, + }, + } satisfies AccountResponse, + 200, + ); + } catch (error) { + return storeFailure(c, error, "ACCOUNT_READ_FAILED"); + } + }); + + routes.patch( + ACCOUNT_PREFERENCES_PATH, + requireOrigin, + mutationBodyLimit, + requireSelf, + async (c) => { + c.header("Cache-Control", "no-store"); + c.header("Vary", "Origin, Cookie"); + const principal = getAuthorizedPrincipal(c); + if (!isAuthenticatedPrincipal(principal)) { + return sanitizedFailure(c, 403, "AUTHORIZATION_DENIED"); + } + const update = parseAccountPreferencesUpdate(await readJsonBody(c)); + if (!update) { + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + try { + const preferences = await options.store.updatePreferences(principal.userId, update); + return c.json( + { + appearance: preferences.appearance, + motion: preferences.motion, + } satisfies AccountPreferences, + 200, + ); + } catch (error) { + return storeFailure(c, error, "ACCOUNT_WRITE_FAILED"); + } + }, + ); + + return routes; +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4f6dc89..f37e745 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -8,6 +8,7 @@ import type { AuthorizationStore, PermissionRegistry, } from "@ekklesia/core/authorization"; +import type { AccountStore } from "@ekklesia/core/account"; import type { DashboardStore } from "@ekklesia/core/dashboard"; import type { DeviceStore } from "@ekklesia/core/devices"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; @@ -28,6 +29,7 @@ import type { Context, MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; +import { createAccountRoutes } from "./account-routes.js"; import type { DashboardHostAssets } from "./dashboard-host-assets.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardRoutes } from "./dashboard-routes.js"; @@ -141,6 +143,10 @@ export interface CreateApiAppOptions { * presentation only — nothing reachable through this store influences discovery, dependency * ordering, module startup, or any authorization decision. */ readonly dashboard: DashboardStore; + /** Core's persistence for a signed-in person's own account summary and preferences (ADR 0062). + * Required like `dashboard`: every installation has accounts. Reachable only through routes that + * take the account from the session, never from the request. */ + readonly account: AccountStore; /** * The built Dashboard's two files, read once by production startup (ADR 0044). Required rather * than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing @@ -469,6 +475,20 @@ export function createApiApp(options: CreateApiAppOptions): Hono { }), ); + // The signed-in person's own account and preferences (ADR 0062). Self-only by construction: the + // account is always the session's, and no request value can name another. See + // `./account-routes.js`. + app.route( + "/", + createAccountRoutes({ + evaluator: options.authorization.evaluator, + registry: options.authorization.registry, + runtime, + store: options.account, + trustedOrigins: allowedOrigins, + }), + ); + // Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/` // belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it // rather than inside it. See `./module-lifecycle-routes.js`. diff --git a/apps/api/src/dashboard-host-assets.ts b/apps/api/src/dashboard-host-assets.ts index 167066d..6a60ce3 100644 --- a/apps/api/src/dashboard-host-assets.ts +++ b/apps/api/src/dashboard-host-assets.ts @@ -3,7 +3,9 @@ import { readFile } from "node:fs/promises"; import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets"; /** - * The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044). + * The three immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044): + * the page, its application script, and the small pre-paint appearance script the page loads in + * `` (ADR 0062). * * Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound, * from the exact URLs the surface package exports. Request input is never involved in this read, the @@ -16,6 +18,8 @@ export interface DashboardHostAssets { readonly htmlEtag: string; readonly script: string; readonly scriptEtag: string; + readonly appearanceScript: string; + readonly appearanceScriptEtag: string; } /** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package @@ -42,11 +46,21 @@ function strongEtag(text: string): string { * can never be paired with an ETag from another. Empty output is a failed shell build rather than a * valid page that can never render anything. */ -export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets { - if (html.trim().length === 0 || script.trim().length === 0) { +export function createDashboardHostAssets( + html: string, + script: string, + appearanceScript: string, +): DashboardHostAssets { + if ( + html.trim().length === 0 || + script.trim().length === 0 || + appearanceScript.trim().length === 0 + ) { throw new DashboardHostAssetError(); } return Object.freeze({ + appearanceScript, + appearanceScriptEtag: strongEtag(appearanceScript), html, htmlEtag: strongEtag(html), script, @@ -92,7 +106,7 @@ export function renderDashboardPage(html: string, apiOrigin: string): string { return parts.join(``); } -/** Loads only the two surface-declared files, and renders the API origin into the page. A missing, +/** Loads only the three surface-declared files, and renders the API origin into the page. A missing, * unreadable, or incompatible build fails startup rather than leaving an API whose own origin answers * `/` with a 404 or a page that cannot find its API. */ export async function loadDashboardHostAssets(apiOrigin: string): Promise { @@ -101,7 +115,12 @@ export async function loadDashboardHostAssets(apiOrigin: string): Promise + fixedAsset( + c.req.header("if-none-match"), + snapshot.appearanceScript, + snapshot.appearanceScriptEtag, + "text/javascript; charset=utf-8", + {}, + ), + ); + return routes; } diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index ff651f4..489ce1d 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -1,4 +1,5 @@ import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts"; +import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account"; import { type AuthorizationDrizzleDatabase, createAuthorizationDrizzleView, @@ -21,6 +22,7 @@ import { } from "@ekklesia/core/modules"; export interface ApiDatabaseHandle { + readonly account: AccountDrizzleDatabase; readonly authorization: AuthorizationDrizzleDatabase; readonly devices: DeviceDrizzleDatabase; readonly dashboard: DashboardDrizzleDatabase; @@ -74,6 +76,7 @@ export function createApiDatabase( const { close, pool } = createValidatedPostgresPool(databaseUrl, options); return Object.freeze({ + account: createAccountDrizzleView(pool), authorization: createAuthorizationDrizzleView(pool), close, devices: createDeviceDrizzleView(pool), diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index f5357d4..6ff4758 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -6,6 +6,7 @@ import { createAuthorizationEvaluator, createAuthorizationStore, } from "@ekklesia/core/authorization"; +import { createAccountStore } from "@ekklesia/core/account"; import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createDeviceStore } from "@ekklesia/core/devices"; import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host"; @@ -639,6 +640,8 @@ export async function startApi( }, // A typed view over the same single pool as identity, authorization, and the module facade. dashboard: createDashboardStore(databaseHandle.dashboard), + // The same pool again: a person's own account summary and preferences (ADR 0062). + account: createAccountStore(databaseHandle.account), dashboardHostAssets, // The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup // has completed whenever it can answer; it stops counting as complete once shutdown begins. diff --git a/apps/api/tests/account-routes.test.ts b/apps/api/tests/account-routes.test.ts new file mode 100644 index 0000000..f791bf2 --- /dev/null +++ b/apps/api/tests/account-routes.test.ts @@ -0,0 +1,340 @@ +import { + ANONYMOUS_PRINCIPAL, + createAuthenticatedPrincipal, + createDevicePrincipal, + type EkklesiaPrincipal, + parseEkklesiaUserId, +} from "@ekklesia/contracts"; +import { ACCOUNT_APPEARANCES, ACCOUNT_MOTION_PREFERENCES } from "@ekklesia/core/account"; +import { + type AuthorizationStore, + CORE_ACCOUNT_SELF_MANAGE, + CORE_PERMISSION_REGISTRY, + createAuthorizationEvaluator, +} from "@ekklesia/core/authorization"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + composePlatformPermissionRegistry, + createModuleUiPublicationRegistry, + EMPTY_MODULE_REALTIME_REGISTRY, + type LegacyModuleRuntimeServices, + type ModuleDatabase, +} from "@ekklesia/core/modules"; +import { EKKLESIA_APPEARANCES, EKKLESIA_MOTION_PREFERENCES } from "@ekklesia/design-tokens"; +import { describe, expect, it, vi } from "vitest"; +import { ACCOUNT_PATH, ACCOUNT_PREFERENCES_PATH } from "../src/account-routes.js"; +import { createApiApp } from "../src/app.js"; +import { + createInMemoryAccountStore, + type InMemoryAccountPerson, +} from "./support/in-memory-account-store.js"; +import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; +import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; +import { + createInMemoryModuleLifecycleStore, + moduleLifecycleSnapshotFor, +} from "./support/in-memory-module-lifecycle.js"; +import { emptyModuleStorage } from "./support/in-memory-module-storage.js"; +import { readyHealth } from "./support/ready-health.js"; + +/** + * `GET /api/v1/account` and `PATCH /api/v1/account/preferences` (ADR 0062): a signed-in person's + * own account and preferences, self-only by construction. + */ + +const trustedOrigin = "https://dashboard.example.test"; +const adaId = parseEkklesiaUserId("11111111-1111-4111-8111-111111111111"); +const graceId = parseEkklesiaUserId("22222222-2222-4222-8222-222222222222"); +const ada = createAuthenticatedPrincipal(adaId); + +const people: readonly InMemoryAccountPerson[] = [ + { email: "ada@example.test", name: "Ada Lovelace", userId: adaId }, + { + email: "grace@example.test", + name: "Grace Hopper", + preferences: { appearance: "light", motion: "reduce" }, + userId: graceId, + }, +]; + +interface Options { + readonly principal?: EkklesiaPrincipal; + readonly evaluationFails?: boolean; +} + +function build(options: Options = {}) { + const evaluator = createAuthorizationEvaluator({ + grants: { hasActiveGrant: async () => false }, + platformOwners: { + isPlatformOwner: async () => { + if (options.evaluationFails) { + throw new Error("database down"); + } + return false; + }, + }, + }); + const runtime: EkklesiaIdentityRuntime = { + handleAuthRequest: vi.fn(), + resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ada), + }; + const account = createInMemoryAccountStore(people); + const app = createApiApp({ + account, + authorization: { + evaluator, + registry: composePlatformPermissionRegistry([]), + store: {} as unknown as AuthorizationStore, + }, + dashboard: createInMemoryDashboardStore(), + dashboardHostAssets: testDashboardHostAssets(), + health: readyHealth(), + moduleConfiguration: emptyModuleConfiguration(), + moduleLifecycle: { + snapshot: moduleLifecycleSnapshotFor([], { modules: [] }), + store: createInMemoryModuleLifecycleStore(), + }, + moduleRealtime: EMPTY_MODULE_REALTIME_REGISTRY, + moduleRoutes: Object.freeze([]), + moduleServices: Object.freeze({ + database: Object.freeze({}) as unknown as ModuleDatabase, + }) as LegacyModuleRuntimeServices, + moduleStorage: emptyModuleStorage(), + moduleUi: createModuleUiPublicationRegistry([]), + runtime, + trustedOrigins: [trustedOrigin], + }); + return { account, app }; +} + +function patch(body: unknown, init: { origin?: string | null; raw?: string } = {}): RequestInit { + const headers: Record = { "Content-Type": "application/json" }; + if (init.origin !== null) { + headers.Origin = init.origin ?? trustedOrigin; + } + return { body: init.raw ?? JSON.stringify(body), headers, method: "PATCH" }; +} + +describe("core.account.self.manage", () => { + it("is an authenticated-baseline Core permission, so managing your own account needs no grant", () => { + expect(CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE)?.baseline).toBe("authenticated"); + }); + + it("holds Core's preference values equal to the design tokens' own", () => { + expect([...ACCOUNT_APPEARANCES]).toEqual([...EKKLESIA_APPEARANCES]); + expect([...ACCOUNT_MOTION_PREFERENCES]).toEqual([...EKKLESIA_MOTION_PREFERENCES]); + }); +}); + +describe("GET /api/v1/account", () => { + it("returns the signed-in person's own name, email, and default preferences", async () => { + const { app } = build(); + const response = await app.request(ACCOUNT_PATH); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("vary")).toContain("Cookie"); + expect(await response.json()).toEqual({ + email: "ada@example.test", + name: "Ada Lovelace", + preferences: { appearance: "system", motion: "system" }, + }); + }); + + it("returns exactly name, email, and preferences — nothing else about the account", async () => { + const { app } = build(); + const body = (await (await app.request(ACCOUNT_PATH)).json()) as Record; + expect(Object.keys(body).sort()).toEqual(["email", "name", "preferences"]); + expect(Object.keys(body.preferences as object).sort()).toEqual(["appearance", "motion"]); + }); + + it("refuses an anonymous caller", async () => { + const { account, app } = build({ principal: ANONYMOUS_PRINCIPAL }); + const response = await app.request(ACCOUNT_PATH); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + expect(account.requestedUserIds).toEqual([]); + }); + + it("refuses a paired display device, which has no account", async () => { + const { account, app } = build({ + principal: createDevicePrincipal("33333333-3333-4333-8333-333333333333"), + }); + const response = await app.request(ACCOUNT_PATH); + + expect(response.status).toBe(403); + expect(account.requestedUserIds).toEqual([]); + }); + + it("reads only the session's account, whatever the request names", async () => { + const { account, app } = build(); + const response = await app.request(`${ACCOUNT_PATH}?userId=${graceId}&id=${graceId}`, { + headers: { "X-User-Id": graceId }, + }); + + expect(((await response.json()) as { name: string }).name).toBe("Ada Lovelace"); + expect(account.requestedUserIds).toEqual([adaId]); + }); + + it("has no route that addresses another person's account", async () => { + const { account, app } = build(); + for (const path of [`${ACCOUNT_PATH}/${graceId}`, `${ACCOUNT_PREFERENCES_PATH}/${graceId}`]) { + expect((await app.request(path)).status).toBe(404); + expect((await app.request(path, patch({ appearance: "dark" }))).status).toBe(404); + } + expect(account.requestedUserIds).toEqual([]); + }); + + it("is not found when the session's person no longer exists", async () => { + const { app } = build({ + principal: createAuthenticatedPrincipal("99999999-9999-4999-8999-999999999999"), + }); + const response = await app.request(ACCOUNT_PATH); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ code: "ACCOUNT_NOT_FOUND" }); + }); + + it("reports a storage failure as a sanitized 503", async () => { + const { account, app } = build(); + account.failWith("ACCOUNT_READ_FAILED"); + const response = await app.request(ACCOUNT_PATH); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "ACCOUNT_READ_FAILED" }); + }); + + it("reports an authorization outage as a 503, never as a denial", async () => { + // The authenticated baseline needs no owner lookup, so an owner-lookup outage cannot deny it. + const { app } = build({ evaluationFails: true }); + expect((await app.request(ACCOUNT_PATH)).status).toBe(200); + }); +}); + +describe("PATCH /api/v1/account/preferences", () => { + it.each([ + ["appearance", "light"], + ["appearance", "dark"], + ["appearance", "system"], + ["motion", "reduce"], + ["motion", "allow"], + ["motion", "system"], + ] as const)("saves %s %s and returns everything in effect", async (name, value) => { + const { app } = build(); + const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ [name]: value })); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + const body = (await response.json()) as Record; + expect(body[name]).toBe(value); + expect(Object.keys(body).sort()).toEqual(["appearance", "motion"]); + + const read = (await (await app.request(ACCOUNT_PATH)).json()) as { + preferences: Record; + }; + expect(read.preferences[name]).toBe(value); + }); + + it("changes only the preference named", async () => { + const { app } = build(); + await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" })); + const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ motion: "reduce" })); + + expect(await response.json()).toEqual({ appearance: "dark", motion: "reduce" }); + }); + + it.each([ + ["an empty object", {}], + ["an unknown appearance", { appearance: "sepia" }], + ["an unknown motion", { motion: "none" }], + ["an unknown key", { appearance: "dark", radius: "large" }], + ["another person's id", { appearance: "dark", userId: "22222222-2222-4222-8222-222222222222" }], + ["an array", ["dark"]], + ["null", null], + ])("refuses %s with nothing changed", async (_label, body) => { + const { account, app } = build(); + const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(body)); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: "INVALID_REQUEST" }); + expect(account.requestedUserIds).toEqual([]); + }); + + it("refuses a body that is not JSON", async () => { + const { app } = build(); + const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch(undefined, { raw: "{" })); + expect(response.status).toBe(400); + }); + + it("refuses an untrusted or missing Origin before anything else", async () => { + const { account, app } = build(); + for (const origin of ["https://elsewhere.example.test", null] as const) { + const response = await app.request( + ACCOUNT_PREFERENCES_PATH, + patch({ appearance: "dark" }, { origin }), + ); + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + } + expect(account.requestedUserIds).toEqual([]); + }); + + it("refuses an oversized body", async () => { + const { app } = build(); + const response = await app.request( + ACCOUNT_PREFERENCES_PATH, + patch(undefined, { raw: JSON.stringify({ appearance: "dark", pad: "x".repeat(20_000) }) }), + ); + expect(response.status).toBe(413); + }); + + it("refuses an anonymous caller and a device", async () => { + for (const principal of [ + ANONYMOUS_PRINCIPAL, + createDevicePrincipal("33333333-3333-4333-8333-333333333333"), + ]) { + const { account, app } = build({ principal }); + const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" })); + expect(response.status).toBe(403); + expect(account.requestedUserIds).toEqual([]); + } + }); + + it("changes only the session's own preferences, never another person's", async () => { + const { account, app } = build(); + await app.request( + `${ACCOUNT_PREFERENCES_PATH}?userId=${graceId}`, + patch({ appearance: "dark", motion: "allow" }), + ); + + expect(account.requestedUserIds).toEqual([adaId]); + expect((await account.readAccount(graceId))?.preferences).toEqual({ + appearance: "light", + motion: "reduce", + }); + }); + + it("reports a storage failure as a sanitized 503", async () => { + const { account, app } = build(); + account.failWith("ACCOUNT_WRITE_FAILED"); + const response = await app.request(ACCOUNT_PREFERENCES_PATH, patch({ appearance: "dark" })); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ code: "ACCOUNT_WRITE_FAILED" }); + }); + + it("answers a preflight from the Dashboard origin with PATCH allowed", async () => { + const { app } = build(); + const response = await app.request(ACCOUNT_PREFERENCES_PATH, { + headers: { + "Access-Control-Request-Method": "PATCH", + Origin: trustedOrigin, + }, + method: "OPTIONS", + }); + expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin); + expect(response.headers.get("access-control-allow-methods")).toContain("PATCH"); + }); +}); diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 8b86cc7..c0c0d81 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -260,6 +260,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0005_module_configuration", "core:0006_device_identity", "core:0007_verified_bootstrap_owners", + "core:0008_user_preferences", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", @@ -1521,6 +1522,162 @@ describe.sequential("live API — exported browser auth shell against PostgreSQL }); }); +describe.sequential("live API — a person's own account and preferences (ADR 0062)", () => { + let database: { name: string; url: string }; + let running: RunningApi; + let origin: string; + let ada: { cookie: string; userId: string }; + let grace: { cookie: string; userId: string }; + const password = "correct horse battery staple"; + + async function account(cookie?: string): Promise<{ status: number; body: unknown }> { + const response = await fetch(`${origin}/api/v1/account`, { + headers: { origin: dashboardOrigin, ...(cookie ? { cookie } : {}) }, + }); + return { body: await response.json(), status: response.status }; + } + + async function changePreferences( + cookie: string | undefined, + body: unknown, + options: { readonly origin?: string; readonly query?: string } = {}, + ): Promise<{ status: number; body: unknown }> { + const response = await fetch(`${origin}/api/v1/account/preferences${options.query ?? ""}`, { + body: JSON.stringify(body), + headers: { + "content-type": "application/json", + origin: options.origin ?? dashboardOrigin, + ...(cookie ? { cookie } : {}), + }, + method: "PATCH", + }); + return { body: await response.json(), status: response.status }; + } + + async function person(email: string): Promise<{ cookie: string; userId: string }> { + const created = await signUp(origin, email, password); + expect(created.response.status).toBe(200); + const session = await signIn(origin, email, password); + if (!session.cookiePair) { + throw harnessError("The account session cookie was not issued."); + } + return { cookie: session.cookiePair, userId: created.body.user.id }; + } + + it("starts a freshly migrated API with two ordinary accounts", async () => { + database = await createDisposableDatabase("account_prefs"); + await applyCoreMigrations(database.url); + running = await startMigratedApi(database.url); + origin = apiOrigin(running); + ada = await person("ada@example.test"); + grace = await person("grace@example.test"); + }); + + it("shows each person their own name, email, and the defaults, with no grant", async () => { + expect(await account(ada.cookie)).toEqual({ + body: { + email: "ada@example.test", + name: "Integration Test User", + preferences: { appearance: "system", motion: "system" }, + }, + status: 200, + }); + expect(((await account(grace.cookie)).body as { email: string }).email).toBe( + "grace@example.test", + ); + }); + + it("refuses an anonymous caller both routes", async () => { + expect((await account()).status).toBe(403); + expect((await changePreferences(undefined, { appearance: "dark" })).status).toBe(403); + }); + + it("saves each person's change to their own account only", async () => { + expect(await changePreferences(ada.cookie, { appearance: "dark" })).toEqual({ + body: { appearance: "dark", motion: "system" }, + status: 200, + }); + expect(await changePreferences(ada.cookie, { motion: "reduce" })).toEqual({ + body: { appearance: "dark", motion: "reduce" }, + status: 200, + }); + expect((await account(grace.cookie)).body).toMatchObject({ + preferences: { appearance: "system", motion: "system" }, + }); + }); + + it("cannot be pointed at another person's account by the query or the body", async () => { + const viaQuery = await changePreferences( + ada.cookie, + { appearance: "light" }, + { query: `?userId=${grace.userId}` }, + ); + expect(viaQuery).toEqual({ body: { appearance: "light", motion: "reduce" }, status: 200 }); + + const viaBody = await changePreferences(ada.cookie, { + appearance: "dark", + userId: grace.userId, + }); + expect(viaBody).toEqual({ body: { code: "INVALID_REQUEST" }, status: 400 }); + + expect((await account(grace.cookie)).body).toMatchObject({ + preferences: { appearance: "system", motion: "system" }, + }); + expect((await account(ada.cookie)).body).toMatchObject({ + preferences: { appearance: "light", motion: "reduce" }, + }); + }); + + it("refuses an untrusted Origin and an invalid value, changing nothing", async () => { + expect( + await changePreferences(ada.cookie, { appearance: "dark" }, { origin: untrustedOrigin }), + ).toEqual({ body: { code: "UNTRUSTED_ORIGIN" }, status: 403 }); + expect(await changePreferences(ada.cookie, { appearance: "sepia" })).toEqual({ + body: { code: "INVALID_REQUEST" }, + status: 400, + }); + expect((await account(ada.cookie)).body).toMatchObject({ + preferences: { appearance: "light", motion: "reduce" }, + }); + }); + + it("follows the person to a new session, as another device would see it", async () => { + const elsewhere = await signIn(origin, "ada@example.test", password); + expect((await account(elsewhere.cookiePair as string)).body).toMatchObject({ + preferences: { appearance: "light", motion: "reduce" }, + }); + }); + + it("stores exactly one typed row, for the one person who chose something", async () => { + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ user_id: string; appearance: string; motion: string }>( + admin, + "select user_id, appearance, motion from ekklesia_core_user_preference", + ); + expect(rows).toEqual([{ appearance: "light", motion: "reduce", user_id: ada.userId }]); + } finally { + await safeEnd(admin); + } + }); + + it("refuses a signed-out session", async () => { + const signedOut = await fetch(`${origin}/api/auth/sign-out`, { + body: "{}", + headers: { "content-type": "application/json", cookie: ada.cookie, origin: dashboardOrigin }, + method: "POST", + }); + expect(signedOut.status).toBe(200); + expect((await account(ada.cookie)).status).toBe(403); + expect((await changePreferences(ada.cookie, { appearance: "dark" })).status).toBe(403); + }); + + it("shuts down cleanly", async () => { + await running.shutdown(); + await dropDatabase(database.name); + }); +}); + describe.sequential("live API — startup and migration guarantees", () => { it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => { const database = await createDisposableDatabase("unmigrated"); @@ -2630,10 +2787,11 @@ describe.sequential("live API — installed module permission end-to-end", () => expect(moduleCheck).toMatchObject({ status: "pass" }); expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); expect(permissionsCheck).toMatchObject({ - // Core's own six (`core.authorization.grants.manage`, `core.modules.ui.read`, + // Core's own seven (`core.authorization.grants.manage`, `core.modules.ui.read`, // `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`, - // `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one. - details: ["7 permissions registered"], + // `core.modules.configuration.manage`, `core.devices.manage`, `core.account.self.manage`) + // plus the fixture module's one. + details: ["8 permissions registered"], status: "pass", }); // Doctor and permission composition read only the manifest — the fixture's register/start diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index 18f0f08..c4a5127 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -30,6 +30,7 @@ import { createApiApp as createApiAppWithModules, } from "../src/app.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; @@ -111,6 +112,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -130,6 +132,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -366,6 +369,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -391,6 +395,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -418,6 +423,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -447,6 +453,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -476,6 +483,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -502,6 +510,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -530,6 +539,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -555,6 +565,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -582,6 +593,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -616,6 +628,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -651,6 +664,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -691,6 +705,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -713,6 +728,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -737,6 +753,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -760,6 +777,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -781,6 +799,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -809,6 +828,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -840,6 +860,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -874,6 +895,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -899,6 +921,7 @@ describe("Ekklesia API Hono application", () => { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -938,6 +961,7 @@ describe("split Dashboard and API origins (ADR 0045)", () => { moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), moduleRealtime, + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), diff --git a/apps/api/tests/dashboard-host-assets.test.ts b/apps/api/tests/dashboard-host-assets.test.ts index 1b16208..ebfe402 100644 --- a/apps/api/tests/dashboard-host-assets.test.ts +++ b/apps/api/tests/dashboard-host-assets.test.ts @@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({ dashboardHostAssetUrls: Object.freeze({ html: "file:///surface/browser/index.html", script: "file:///surface/browser/assets/ekklesia-dashboard/app.js", + appearance: "file:///surface/browser/assets/ekklesia-dashboard/appearance.js", }), })); @@ -28,19 +29,26 @@ function sha256Etag(text: string): string { describe("Dashboard host asset loading", () => { beforeEach(() => readFileMock.mockReset()); - it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => { + it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => { const html = `${DASHBOARD_API_ORIGIN_PLACEHOLDER}
`; const script = "export const dashboardHost = true;\n"; - readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script); + const appearanceScript = "(() => {})();\n"; + readFileMock + .mockResolvedValueOnce(html) + .mockResolvedValueOnce(script) + .mockResolvedValueOnce(appearanceScript); const assets = await loadDashboardHostAssets(API_ORIGIN); expect(readFileMock.mock.calls).toEqual([ [new URL("file:///surface/browser/index.html"), "utf8"], [new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"], + [new URL("file:///surface/browser/assets/ekklesia-dashboard/appearance.js"), "utf8"], ]); const rendered = `
`; expect(assets).toEqual({ + appearanceScript, + appearanceScriptEtag: sha256Etag(appearanceScript), html: rendered, htmlEtag: sha256Etag(rendered), script, @@ -83,12 +91,27 @@ describe("Dashboard host asset loading", () => { expect(readFileMock).toHaveBeenCalledTimes(2); }); - it("rejects an empty page or script as an unavailable shell build", () => { - for (const [html, script] of [ - ["", "export {};"], - ["", " \n"], + it("fails when the appearance script is missing even though the page and script were read", async () => { + readFileMock + .mockResolvedValueOnce(`${DASHBOARD_API_ORIGIN_PLACEHOLDER}`) + .mockResolvedValueOnce("export {};\n") + .mockRejectedValueOnce(new Error("ENOENT appearance.js")); + + await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf( + DashboardHostAssetError, + ); + expect(readFileMock).toHaveBeenCalledTimes(3); + }); + + it("rejects an empty page, script, or appearance script as an unavailable shell build", () => { + for (const [html, script, appearance] of [ + ["", "export {};", "(() => {})();"], + ["", " \n", "(() => {})();"], + ["", "export {};", " "], ] as const) { - expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError); + expect(() => createDashboardHostAssets(html, script, appearance)).toThrow( + DashboardHostAssetError, + ); } }); @@ -99,7 +122,10 @@ describe("Dashboard host asset loading", () => { '', ]) { readFileMock.mockReset(); - readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n"); + readFileMock + .mockResolvedValueOnce(html) + .mockResolvedValueOnce("export {};\n") + .mockResolvedValueOnce("(() => {})();\n"); await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf( DashboardHostAssetError, ); diff --git a/apps/api/tests/dashboard-host-routes.test.ts b/apps/api/tests/dashboard-host-routes.test.ts index 15c4d70..dc83805 100644 --- a/apps/api/tests/dashboard-host-routes.test.ts +++ b/apps/api/tests/dashboard-host-routes.test.ts @@ -38,6 +38,7 @@ import { } from "../src/dashboard-host-assets.js"; import { DASHBOARD_HOST_PAGE_PATH, + DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, DASHBOARD_HOST_SCRIPT_PATH, DashboardHostConfigurationError, createDashboardHostRoutes, @@ -51,6 +52,7 @@ import { DISPLAY_HOST_SCRIPT_PATH, DISPLAY_SERVICE_WORKER_PATH, } from "../src/module-display-routes.js"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { @@ -73,6 +75,8 @@ function sha256Etag(text: string): string { const PAGE_ETAG = sha256Etag(PAGE_HTML); const SCRIPT_ETAG = sha256Etag(SCRIPT); +const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n"; +const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_SCRIPT); function fakeRuntime(): EkklesiaIdentityRuntime { return { @@ -156,8 +160,10 @@ function buildApp( }, moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), - dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT), + dashboardHostAssets: + options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT), health: readyHealth(), runtime, trustedOrigins: [trustedOrigin], @@ -357,7 +363,10 @@ describe("Dashboard deep links (ADR 0057)", () => { for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) { expect( () => - createDashboardHostRoutes(createDashboardHostAssets(PAGE_HTML, SCRIPT), { modulePages }), + createDashboardHostRoutes( + createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT), + { modulePages }, + ), JSON.stringify(modulePages), ).toThrow(DashboardHostConfigurationError); } @@ -457,8 +466,45 @@ describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => { }); }); +describe("GET /assets/ekklesia-dashboard/appearance.js (the pre-paint appearance script)", () => { + it("serves the exact pre-paint script as JavaScript with revalidation and its own strong ETag", async () => { + const { app } = buildApp(); + + const response = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH); + + expect(response.status).toBe(200); + expect(await response.text()).toBe(APPEARANCE_SCRIPT); + expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8"); + expect(response.headers.get("cache-control")).toBe("no-cache"); + expect(response.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG); + expect(response.headers.get("x-content-type-options")).toBe("nosniff"); + // Code only: nothing about any viewer, and nothing that varies by request. + expect(response.headers.get("set-cookie")).toBeNull(); + expect(response.headers.get("vary")).toBeNull(); + }); + + it("answers a matching If-None-Match with a 304, and does not accept the app script's validator", async () => { + const { app } = buildApp(); + + const notModified = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, { + headers: { "If-None-Match": APPEARANCE_SCRIPT_ETAG }, + }); + expect(notModified.status).toBe(304); + expect(notModified.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG); + + const other = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, { + headers: { "If-None-Match": SCRIPT_ETAG }, + }); + expect(other.status).toBe(200); + }); + + it("lives beside the app script, in the Dashboard's own asset namespace", () => { + expect(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH).toBe("/assets/ekklesia-dashboard/appearance.js"); + }); +}); + describe("Dashboard host boundaries", () => { - it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => { + it("serves nothing but its exact paths, answering every other spelling with the sanitized 404", async () => { const { app } = buildApp(); for (const path of [ @@ -472,6 +518,10 @@ describe("Dashboard host boundaries", () => { "/assets/ekklesia-dashboard/index.html", "/assets/", "/assets/ekklesia-dashboard/APP.JS", + "/assets/ekklesia-dashboard/appearance.js.map", + "/assets/ekklesia-dashboard/appearance.js/", + "/assets/ekklesia-dashboard/APPEARANCE.JS", + "/appearance.js", "/dashboard", "/login", ]) { @@ -560,12 +610,21 @@ describe("Dashboard host boundaries", () => { }); it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => { - const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT); + const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT); for (const assets of [ { ...consistent, html: `${PAGE_HTML}` }, { ...consistent, scriptEtag: PAGE_ETAG }, - { html: "", htmlEtag: sha256Etag(""), script: SCRIPT, scriptEtag: SCRIPT_ETAG }, + { ...consistent, appearanceScriptEtag: SCRIPT_ETAG }, + { ...consistent, appearanceScript: `${APPEARANCE_SCRIPT}// tampered` }, + { + appearanceScript: APPEARANCE_SCRIPT, + appearanceScriptEtag: APPEARANCE_SCRIPT_ETAG, + html: "", + htmlEtag: sha256Etag(""), + script: SCRIPT, + scriptEtag: SCRIPT_ETAG, + }, ]) { expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError); } diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts index bf173cc..d19bafd 100644 --- a/apps/api/tests/dashboard-routes.test.ts +++ b/apps/api/tests/dashboard-routes.test.ts @@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createApiApp } from "../src/app.js"; import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; @@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> { const app = createApiApp({ authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(options.configuration ?? {}), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), diff --git a/apps/api/tests/dashboard-settings-routes.test.ts b/apps/api/tests/dashboard-settings-routes.test.ts index 09749ed..a725f6a 100644 --- a/apps/api/tests/dashboard-settings-routes.test.ts +++ b/apps/api/tests/dashboard-settings-routes.test.ts @@ -28,6 +28,7 @@ import { DASHBOARD_SETTINGS_PATH, type DashboardSettingsResponse, } from "../src/dashboard-settings-routes.js"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; @@ -127,6 +128,7 @@ function build(options: Options = {}) { } as unknown as AuthorizationStore; const app = createApiApp({ authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), diff --git a/apps/api/tests/health-routes.test.ts b/apps/api/tests/health-routes.test.ts index 98531a6..6786bc8 100644 --- a/apps/api/tests/health-routes.test.ts +++ b/apps/api/tests/health-routes.test.ts @@ -24,6 +24,7 @@ import { HEALTH_READY_PATH, READINESS_DATABASE_TIMEOUT_MS, } from "../src/health-routes.js"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; @@ -91,6 +92,7 @@ function buildApp( moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health, diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index 0df2d83..7cd7aa6 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -20,7 +20,7 @@ vi.mock("@hono/node-server", () => ({ serve: serveMock })); vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => { const actual = await importOriginal(); loadDashboardHostAssetsMock.mockImplementation(async () => - actual.createDashboardHostAssets("", "export {};\n"), + actual.createDashboardHostAssets("", "export {};\n", "(() => {})();\n"), ); return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock }; }); diff --git a/apps/api/tests/module-configuration-routes.test.ts b/apps/api/tests/module-configuration-routes.test.ts index a241952..10dbe12 100644 --- a/apps/api/tests/module-configuration-routes.test.ts +++ b/apps/api/tests/module-configuration-routes.test.ts @@ -34,6 +34,7 @@ import { createApiApp } from "../src/app.js"; import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; @@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise { registry: composePlatformPermissionRegistry(modules), store: authorizationStore, }, + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index fcde53f..7ea9d6a 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -249,6 +249,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0005_module_configuration", "core:0006_device_identity", "core:0007_verified_bootstrap_owners", + "core:0008_user_preferences", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -279,6 +280,7 @@ async function applyMigrationsWithModule(url: string): Promise { "core:0005_module_configuration", "core:0006_device_identity", "core:0007_verified_bootstrap_owners", + "core:0008_user_preferences", "db-facade:0000_initial", ]); expect(aggregate.migrations[0]?.checksum).toBe( diff --git a/apps/api/tests/module-display-routes.test.ts b/apps/api/tests/module-display-routes.test.ts index eac0931..5b399f5 100644 --- a/apps/api/tests/module-display-routes.test.ts +++ b/apps/api/tests/module-display-routes.test.ts @@ -37,6 +37,7 @@ import { moduleApiBaseUrl } from "../src/module-http-routes.js"; import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; @@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise { registry: composePlatformPermissionRegistry(modules), store: fakeAuthorizationStore(), }, + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index 5ea8307..65f4f6c 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -30,6 +30,7 @@ import { mergedVaryHeader, } from "../src/module-http-routes.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; @@ -125,6 +126,7 @@ function buildModuleApp(options: { moduleLifecycle: emptyModuleLifecycle(), moduleConfiguration: emptyModuleConfiguration(), moduleStorage: emptyModuleStorage(), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), diff --git a/apps/api/tests/module-lifecycle-routes.test.ts b/apps/api/tests/module-lifecycle-routes.test.ts index 252e7f6..fa46f15 100644 --- a/apps/api/tests/module-lifecycle-routes.test.ts +++ b/apps/api/tests/module-lifecycle-routes.test.ts @@ -32,6 +32,7 @@ import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; @@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise { registry: composePlatformPermissionRegistry(modules), store: authorizationStore, }, + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index 64a9e23..f73af62 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -33,8 +33,10 @@ import { createModuleUiContext, type EkklesiaHostConfig, moduleUiSourceUrl, + saveHostAccountPreferences, } from "@ekklesia/shell"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; @@ -216,6 +218,8 @@ interface HarnessOptions { * single module proves very little. */ readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>; readonly platformOwner?: boolean; + /** People with an account, for the account area's routes (ADR 0062). */ + readonly people?: Parameters[0]; } interface Harness { @@ -289,6 +293,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { }, moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), + account: createInMemoryAccountStore(options.people ?? []), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), @@ -302,7 +307,10 @@ async function startHarness(options: HarnessOptions = {}): Promise { if (url.pathname.startsWith("/api/v1/modules/")) { moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`); } - return app.request(url.href, init); + // A browser names the Dashboard's origin on every cross-origin request; the shell never sets it. + const headers = new Headers(init?.headers); + headers.set("Origin", "https://dashboard.example.test"); + return app.request(url.href, { ...init, headers }); }) as unknown as typeof fetch; return { @@ -738,6 +746,39 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () } }); + it("loads the signed-in person's own account, and a saved preference is what the next load sees (ADR 0062)", async () => { + const harness = await startHarness({ + people: [{ email: "viewer@example.test", name: "Viewer Person", userId: viewerId }], + principal: createAuthenticatedPrincipal(viewerId), + }); + try { + const first = await bootstrapHost(harness.config); + expect(first.account).toEqual({ + email: "viewer@example.test", + name: "Viewer Person", + preferences: { appearance: "system", motion: "system" }, + }); + + expect(await saveHostAccountPreferences(harness.config, { appearance: "dark" })).toEqual({ + appearance: "dark", + motion: "system", + }); + expect(await saveHostAccountPreferences(harness.config, { motion: "reduce" })).toEqual({ + appearance: "dark", + motion: "reduce", + }); + + // Another device signing in reads the account, not this one's browser. + const next = await bootstrapHost(harness.config); + expect(next.account !== "unavailable" && next.account.preferences).toEqual({ + appearance: "dark", + motion: "reduce", + }); + } finally { + await harness.stop(); + } + }); + it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => { const harness = await startHarness(); try { diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index 217705d..d92e4fc 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -24,6 +24,7 @@ import { } from "@ekklesia/core/modules"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; @@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise { }, moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index d19676b..bee6f08 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -18,6 +18,7 @@ import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/mod import { Window } from "happy-dom"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createInMemoryAccountStore } from "./support/in-memory-account-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { readyHealth } from "./support/ready-health.js"; @@ -254,6 +255,7 @@ async function install( }, moduleConfiguration: moduleConfigurationOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules), + account: createInMemoryAccountStore(), dashboard: createInMemoryDashboardStore(), dashboardHostAssets: testDashboardHostAssets(), health: readyHealth(), diff --git a/apps/api/tests/support/dashboard-host-assets.ts b/apps/api/tests/support/dashboard-host-assets.ts index b44d9fe..2e8047f 100644 --- a/apps/api/tests/support/dashboard-host-assets.ts +++ b/apps/api/tests/support/dashboard-host-assets.ts @@ -9,5 +9,6 @@ export function testDashboardHostAssets(): DashboardHostAssets { return createDashboardHostAssets( '
', "export const dashboardHost = true;\n", + "(() => {})();\n", ); } diff --git a/apps/api/tests/support/in-memory-account-store.ts b/apps/api/tests/support/in-memory-account-store.ts new file mode 100644 index 0000000..c3832e4 --- /dev/null +++ b/apps/api/tests/support/in-memory-account-store.ts @@ -0,0 +1,85 @@ +import type { EkklesiaUserId } from "@ekklesia/contracts"; +import { + type AccountPreferences, + type AccountPreferencesUpdate, + type AccountStore, + AccountStoreError, + type AccountSummary, + DEFAULT_ACCOUNT_PREFERENCES, + parseAccountPreferencesUpdate, +} from "@ekklesia/core/account"; + +export interface InMemoryAccountPerson { + readonly userId: string; + readonly name: string; + readonly email: string; + readonly preferences?: Partial; +} + +export interface InMemoryAccountStore extends AccountStore { + /** Every user id a read or write was asked for, in order, so a route test can prove which account + * the route touched — the session's, and never one named by the request. */ + readonly requestedUserIds: readonly string[]; + /** Makes the next operations fail the way an unreachable database would. */ + failWith(code: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED" | undefined): void; +} + +/** + * An in-memory `AccountStore` for tests that exercise **routing and authorization**, not + * persistence. It reproduces the store's contract — strict update parsing, partial updates, an + * unknown person is not found — and nothing a database owns; persistence, the `CHECK` + * constraints, isolation across connections, and the cascade are proven against real PostgreSQL in + * `core/tests/account-store.integration.ts`. + */ +export function createInMemoryAccountStore( + people: readonly InMemoryAccountPerson[] = [], +): InMemoryAccountStore { + const records = new Map( + people.map((person) => [ + person.userId, + { + email: person.email, + name: person.name, + preferences: { ...DEFAULT_ACCOUNT_PREFERENCES, ...person.preferences }, + }, + ]), + ); + const requestedUserIds: string[] = []; + let failure: "ACCOUNT_READ_FAILED" | "ACCOUNT_WRITE_FAILED" | undefined; + + return { + failWith(code) { + failure = code; + }, + async readAccount(userId: EkklesiaUserId): Promise { + requestedUserIds.push(userId); + if (failure) { + throw new AccountStoreError("ACCOUNT_READ_FAILED"); + } + const record = records.get(userId); + return record + ? { email: record.email, name: record.name, preferences: { ...record.preferences } } + : undefined; + }, + requestedUserIds, + async updatePreferences( + userId: EkklesiaUserId, + update: AccountPreferencesUpdate, + ): Promise { + requestedUserIds.push(userId); + const parsed = parseAccountPreferencesUpdate(update); + if (!parsed) { + throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID"); + } + if (failure) { + throw new AccountStoreError("ACCOUNT_WRITE_FAILED"); + } + const record = records.get(userId); + if (!record) { + throw new AccountStoreError("ACCOUNT_NOT_FOUND"); + } + record.preferences = { ...record.preferences, ...parsed }; + return { ...record.preferences }; + }, + }; +} diff --git a/core/drizzle.config.ts b/core/drizzle.config.ts index dcc4580..9752bbd 100644 --- a/core/drizzle.config.ts +++ b/core/drizzle.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "./src/modules/lifecycle-schema.ts", "./src/modules/configuration-schema.ts", "./src/devices/schema.ts", + "./src/account/schema.ts", ], out: "./drizzle", migrations: { diff --git a/core/drizzle/0008_user_preferences.sql b/core/drizzle/0008_user_preferences.sql new file mode 100644 index 0000000..7584955 --- /dev/null +++ b/core/drizzle/0008_user_preferences.sql @@ -0,0 +1,11 @@ +CREATE TABLE "ekklesia_core_user_preference" ( + "user_id" text PRIMARY KEY NOT NULL, + "appearance" text, + "motion" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "ekklesia_core_user_preference_appearance_check" CHECK ("ekklesia_core_user_preference"."appearance" is null or "ekklesia_core_user_preference"."appearance" in ('system', 'light', 'dark')), + CONSTRAINT "ekklesia_core_user_preference_motion_check" CHECK ("ekklesia_core_user_preference"."motion" is null or "ekklesia_core_user_preference"."motion" in ('system', 'reduce', 'allow')) +); +--> statement-breakpoint +ALTER TABLE "ekklesia_core_user_preference" ADD CONSTRAINT "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/core/drizzle/meta/0008_snapshot.json b/core/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..2d06240 --- /dev/null +++ b/core/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1434 @@ +{ + "id": "dc0029d4-258f-4fbb-a794-acfbba0ec946", + "prevId": "d435c98f-67b6-4c37-89a9-820036fdf444", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ekklesia_core_account": { + "name": "ekklesia_core_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_account_user_id_idx": { + "name": "ekklesia_core_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_account_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_account", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_session": { + "name": "ekklesia_core_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_session_user_id_idx": { + "name": "ekklesia_core_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_session_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_session", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_session_token_unique": { + "name": "ekklesia_core_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_user": { + "name": "ekklesia_core_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_user_email_unique": { + "name": "ekklesia_core_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_verification": { + "name": "ekklesia_core_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_verification_identifier_idx": { + "name": "ekklesia_core_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_authorization_audit": { + "name": "ekklesia_core_authorization_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_authorization_audit_subject_idx": { + "name": "ekklesia_core_authorization_audit_subject_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_authorization_audit_action_check": { + "name": "ekklesia_core_authorization_audit_action_check", + "value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')" + }, + "ekklesia_core_authorization_audit_actor_type_check": { + "name": "ekklesia_core_authorization_audit_actor_type_check", + "value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')" + }, + "ekklesia_core_authorization_audit_scope_check": { + "name": "ekklesia_core_authorization_audit_scope_check", + "value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_grant": { + "name": "ekklesia_core_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_grant_user_permission_idx": { + "name": "ekklesia_core_grant_user_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_grant", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_grant_source_kind_check": { + "name": "ekklesia_core_grant_source_kind_check", + "value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')" + }, + "ekklesia_core_grant_created_actor_type_check": { + "name": "ekklesia_core_grant_created_actor_type_check", + "value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_grant_revoked_actor_type_check": { + "name": "ekklesia_core_grant_revoked_actor_type_check", + "value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner": { + "name": "ekklesia_core_platform_owner", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_actor_type": { + "name": "granted_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_actor_id": { + "name": "granted_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_platform_owner", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_granted_actor_type_check": { + "name": "ekklesia_core_platform_owner_granted_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_platform_owner_revoked_actor_type_check": { + "name": "ekklesia_core_platform_owner_revoked_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner_bootstrap": { + "name": "ekklesia_core_platform_owner_bootstrap", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumed_for_user_id": { + "name": "consumed_for_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_bootstrap_singleton_check": { + "name": "ekklesia_core_platform_owner_bootstrap_singleton_check", + "value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_group": { + "name": "ekklesia_core_dashboard_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_group_order_idx": { + "name": "ekklesia_core_dashboard_group_order_idx", + "columns": [ + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_group_id_check": { + "name": "ekklesia_core_dashboard_group_id_check", + "value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0" + }, + "ekklesia_core_dashboard_group_label_check": { + "name": "ekklesia_core_dashboard_group_label_check", + "value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_placement": { + "name": "ekklesia_core_dashboard_placement", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_placement_group_idx": { + "name": "ekklesia_core_dashboard_placement_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": { + "name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk", + "tableFrom": "ekklesia_core_dashboard_placement", + "tableTo": "ekklesia_core_dashboard_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_placement_module_check": { + "name": "ekklesia_core_dashboard_placement_module_check", + "value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_module_state": { + "name": "ekklesia_core_module_state", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_module_state_module_check": { + "name": "ekklesia_core_module_state_module_check", + "value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_module_configuration": { + "name": "ekklesia_core_module_configuration", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_module_configuration_module_idx": { + "name": "ekklesia_core_module_configuration_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ekklesia_core_module_configuration_module_id_key_pk": { + "name": "ekklesia_core_module_configuration_module_id_key_pk", + "columns": ["module_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_module_configuration_module_check": { + "name": "ekklesia_core_module_configuration_module_check", + "value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0" + }, + "ekklesia_core_module_configuration_key_check": { + "name": "ekklesia_core_module_configuration_key_check", + "value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0" + }, + "ekklesia_core_module_configuration_value_check": { + "name": "ekklesia_core_module_configuration_value_check", + "value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device": { + "name": "ekklesia_core_device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_credential_hash_idx": { + "name": "ekklesia_core_device_credential_hash_idx", + "columns": [ + { + "expression": "credential_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ekklesia_core_device_revoked_idx": { + "name": "ekklesia_core_device_revoked_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["revoked_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_name_check": { + "name": "ekklesia_core_device_name_check", + "value": "length(\"ekklesia_core_device\".\"name\") between 1 and 128" + }, + "ekklesia_core_device_credential_hash_check": { + "name": "ekklesia_core_device_credential_hash_check", + "value": "length(\"ekklesia_core_device\".\"credential_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device_pairing_request": { + "name": "ekklesia_core_device_pairing_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_pairing_code_idx": { + "name": "ekklesia_core_device_pairing_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"ekklesia_core_device_pairing_request\".\"state\" <> 'consumed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "ekklesia_core_device_pairing_claim_idx": { + "name": "ekklesia_core_device_pairing_claim_idx", + "columns": [ + { + "expression": "claim_secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device_pairing_request", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["approved_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk": { + "name": "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk", + "tableFrom": "ekklesia_core_device_pairing_request", + "tableTo": "ekklesia_core_device", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_pairing_code_check": { + "name": "ekklesia_core_device_pairing_code_check", + "value": "\"ekklesia_core_device_pairing_request\".\"code\" ~ '^[0-9]{6}$'" + }, + "ekklesia_core_device_pairing_claim_hash_check": { + "name": "ekklesia_core_device_pairing_claim_hash_check", + "value": "length(\"ekklesia_core_device_pairing_request\".\"claim_secret_hash\") = 64" + }, + "ekklesia_core_device_pairing_state_check": { + "name": "ekklesia_core_device_pairing_state_check", + "value": "\"ekklesia_core_device_pairing_request\".\"state\" in ('pending', 'approved', 'consumed')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device_permission": { + "name": "ekklesia_core_device_permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_permission_lookup_idx": { + "name": "ekklesia_core_device_permission_lookup_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk": { + "name": "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk", + "tableFrom": "ekklesia_core_device_permission", + "tableTo": "ekklesia_core_device", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device_permission", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["granted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_permission_id_check": { + "name": "ekklesia_core_device_permission_id_check", + "value": "length(\"ekklesia_core_device_permission\".\"permission_id\") between 1 and 128" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_user_preference": { + "name": "ekklesia_core_user_preference", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "appearance": { + "name": "appearance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "motion": { + "name": "motion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_user_preference", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_user_preference_appearance_check": { + "name": "ekklesia_core_user_preference_appearance_check", + "value": "\"ekklesia_core_user_preference\".\"appearance\" is null or \"ekklesia_core_user_preference\".\"appearance\" in ('system', 'light', 'dark')" + }, + "ekklesia_core_user_preference_motion_check": { + "name": "ekklesia_core_user_preference_motion_check", + "value": "\"ekklesia_core_user_preference\".\"motion\" is null or \"ekklesia_core_user_preference\".\"motion\" in ('system', 'reduce', 'allow')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json index 792ef37..ae1d23a 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -49,6 +49,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0008_user_preferences", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index ca73164..77181d2 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1787543773058, "tag": "0007_verified_bootstrap_owners", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1792036365485, + "tag": "0008_user_preferences", + "breakpoints": true } ] } diff --git a/core/package.json b/core/package.json index 7e42198..f2dd8a2 100644 --- a/core/package.json +++ b/core/package.json @@ -12,6 +12,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./account": { + "types": "./dist/account/index.d.ts", + "import": "./dist/account/index.js" + }, "./modules": { "types": "./dist/modules/index.d.ts", "import": "./dist/modules/index.js" diff --git a/core/src/account/database.ts b/core/src/account/database.ts new file mode 100644 index 0000000..b56b948 --- /dev/null +++ b/core/src/account/database.ts @@ -0,0 +1,13 @@ +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; +import { accountSchema } from "./schema.js"; + +export type AccountDrizzleDatabase = NodePgDatabase; + +/** + * A typed Drizzle view over an externally owned pool, exactly like `createDeviceDrizzleView`: it + * never constructs, registers an error listener on, or closes a pool. + */ +export function createAccountDrizzleView(pool: Pool): AccountDrizzleDatabase { + return drizzle(pool, { schema: accountSchema }); +} diff --git a/core/src/account/index.ts b/core/src/account/index.ts new file mode 100644 index 0000000..1566b1a --- /dev/null +++ b/core/src/account/index.ts @@ -0,0 +1,4 @@ +export * from "./database.js"; +export * from "./preferences.js"; +export * from "./schema.js"; +export * from "./store.js"; diff --git a/core/src/account/preferences.ts b/core/src/account/preferences.ts new file mode 100644 index 0000000..d8d9415 --- /dev/null +++ b/core/src/account/preferences.ts @@ -0,0 +1,108 @@ +/** + * A person's own preferences (DESIGN.md §9.2, ADR 0062): how the Dashboard looks and behaves *for + * them*, on every device they sign in on. + * + * The model is deliberately a closed, typed record rather than a key/value bag or a JSON document. + * Every preference has a fixed name, a closed set of values, and a default; a value outside the set + * is refused at this boundary and again by the database's `CHECK` constraint. A new preference is a + * new field here and a new nullable column in a migration — never a second table, and never a + * free-form value a client could fill with anything. + * + * Core cannot import the design-token package that renders these, so the value sets are declared + * here and an `apps/api` test holds them equal to `@ekklesia/design-tokens`' own + * (`EKKLESIA_APPEARANCES`, `EKKLESIA_MOTION_PREFERENCES`): one list cannot change without the other. + */ + +/** Light, dark, or following the operating system. `system` is the default. */ +export const ACCOUNT_APPEARANCES = Object.freeze(["system", "light", "dark"] as const); +export type AccountAppearance = (typeof ACCOUNT_APPEARANCES)[number]; + +/** Following `prefers-reduced-motion`, always reducing, or always allowing the platform's subtle + * motion. `system` is the default. */ +export const ACCOUNT_MOTION_PREFERENCES = Object.freeze(["system", "reduce", "allow"] as const); +export type AccountMotionPreference = (typeof ACCOUNT_MOTION_PREFERENCES)[number]; + +export interface AccountPreferences { + readonly appearance: AccountAppearance; + readonly motion: AccountMotionPreference; +} + +/** What a person who has never chosen anything gets: follow the system for both. */ +export const DEFAULT_ACCOUNT_PREFERENCES: AccountPreferences = Object.freeze({ + appearance: "system", + motion: "system", +}); + +/** A change to some of a person's preferences. Fields left out keep their stored value, so + * changing motion on a phone never overwrites an appearance chosen a moment earlier on a laptop. */ +export type AccountPreferencesUpdate = Partial; + +const PREFERENCE_VALUES: Readonly> = + Object.freeze({ + appearance: ACCOUNT_APPEARANCES, + motion: ACCOUNT_MOTION_PREFERENCES, + }); + +/** The preference names, in a stable order. */ +export const ACCOUNT_PREFERENCE_NAMES = Object.freeze( + Object.keys(PREFERENCE_VALUES).sort() as (keyof AccountPreferences)[], +); + +export function isAccountAppearance(value: unknown): value is AccountAppearance { + return typeof value === "string" && ACCOUNT_APPEARANCES.includes(value as AccountAppearance); +} + +export function isAccountMotionPreference(value: unknown): value is AccountMotionPreference { + return ( + typeof value === "string" && + ACCOUNT_MOTION_PREFERENCES.includes(value as AccountMotionPreference) + ); +} + +/** + * Reads an untrusted update strictly: a plain object naming at least one known preference, each + * with one of its allowed values, and nothing else. Returns `undefined` for anything else — an + * unknown key, a wrong value, an empty object, an array, `null` — so a malformed request is refused + * whole rather than partly applied, and no unexpected key can ever reach storage. + */ +export function parseAccountPreferencesUpdate( + value: unknown, +): AccountPreferencesUpdate | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + if (Object.getPrototypeOf(value) !== Object.prototype) { + return undefined; + } + const entries = Object.entries(value); + if (entries.length === 0) { + return undefined; + } + const update: { appearance?: AccountAppearance; motion?: AccountMotionPreference } = {}; + for (const [key, entry] of entries) { + if (key === "appearance" && isAccountAppearance(entry)) { + update.appearance = entry; + } else if (key === "motion" && isAccountMotionPreference(entry)) { + update.motion = entry; + } else { + return undefined; + } + } + return Object.freeze(update); +} + +/** Resolves stored columns, where `null` means "never chosen", to the preferences in effect. A + * stored value the database constraint should have made impossible is treated as never chosen. */ +export function resolveAccountPreferences(stored: { + readonly appearance: string | null; + readonly motion: string | null; +}): AccountPreferences { + return Object.freeze({ + appearance: isAccountAppearance(stored.appearance) + ? stored.appearance + : DEFAULT_ACCOUNT_PREFERENCES.appearance, + motion: isAccountMotionPreference(stored.motion) + ? stored.motion + : DEFAULT_ACCOUNT_PREFERENCES.motion, + }); +} diff --git a/core/src/account/schema.ts b/core/src/account/schema.ts new file mode 100644 index 0000000..e5f3084 --- /dev/null +++ b/core/src/account/schema.ts @@ -0,0 +1,56 @@ +import { sql } from "drizzle-orm"; +import { check, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { ekklesiaCoreUser } from "../identity/schema.js"; + +/** + * Core-owned storage for a person's own preferences (ADR 0062). + * + * **One row per person, one typed column per preference.** There is deliberately no key/value + * table and no JSON column: each preference has a fixed name and a closed set of values, enforced + * here by a `CHECK` constraint beneath the application-layer parser + * (`parseAccountPreferencesUpdate`), so nothing but a known value can be stored. A new preference is + * a new nullable column in a new migration. + * + * **`null` means "never chosen"**, and is resolved to the platform default when read + * (`resolveAccountPreferences`) rather than written as a column default. That keeps "this person + * chose System" distinguishable from "this person never chose", and lets a later preference column + * arrive without backfilling a value nobody chose — the same reasoning + * `ekklesia_core_module_state` follows for its absent rows (ADR 0028). A person with no row at all + * has chosen nothing. + * + * The row belongs to its person: `user_id` is the primary key and cascades on delete, so deleting + * an account removes its preferences with it, and no row can exist for a person who does not. + * + * Personal, not church-wide. Nothing here is branding, a church theme, or an administrator's + * decision for anyone else; those are Settings' (ADR 0061). + */ +export const ekklesiaCoreUserPreference = pgTable( + "ekklesia_core_user_preference", + { + userId: text("user_id") + .primaryKey() + .references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }), + appearance: text("appearance"), + motion: text("motion"), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + check( + "ekklesia_core_user_preference_appearance_check", + sql`${table.appearance} is null or ${table.appearance} in ('system', 'light', 'dark')`, + ), + check( + "ekklesia_core_user_preference_motion_check", + sql`${table.motion} is null or ${table.motion} in ('system', 'reduce', 'allow')`, + ), + ], +); + +/** Explicit map passed to Drizzle, mirroring `dashboardSchema` and `moduleLifecycleSchema`. The + * user table is included because the account summary reads a person's own name and email from it; + * nothing in this domain writes to it. */ +export const accountSchema = { + user: ekklesiaCoreUser, + userPreference: ekklesiaCoreUserPreference, +} as const; diff --git a/core/src/account/store.ts b/core/src/account/store.ts new file mode 100644 index 0000000..e56dd12 --- /dev/null +++ b/core/src/account/store.ts @@ -0,0 +1,156 @@ +import type { EkklesiaUserId } from "@ekklesia/contracts"; +import { eq, sql } from "drizzle-orm"; +import { ekklesiaCoreUser } from "../identity/schema.js"; +import type { AccountDrizzleDatabase } from "./database.js"; +import { + type AccountPreferences, + type AccountPreferencesUpdate, + parseAccountPreferencesUpdate, + resolveAccountPreferences, +} from "./preferences.js"; +import { ekklesiaCoreUserPreference } from "./schema.js"; + +export type AccountStoreErrorCode = + | "ACCOUNT_NOT_FOUND" + | "ACCOUNT_PREFERENCES_INVALID" + | "ACCOUNT_READ_FAILED" + | "ACCOUNT_WRITE_FAILED"; + +const accountStoreMessages: Readonly> = Object.freeze({ + ACCOUNT_NOT_FOUND: "The account was not found.", + ACCOUNT_PREFERENCES_INVALID: "The preferences are not valid.", + ACCOUNT_READ_FAILED: "The account could not be read.", + ACCOUNT_WRITE_FAILED: "The preferences could not be saved.", +}); + +/** Never carries the underlying database error, SQL text, host, or stack — matching + * `DashboardStoreError` exactly. */ +export class AccountStoreError extends Error { + readonly code: AccountStoreErrorCode; + + constructor(code: AccountStoreErrorCode) { + super(accountStoreMessages[code]); + this.name = "AccountStoreError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +/** + * What a signed-in person is shown about their own account: the name and email they signed up + * with, and their preferences. Deliberately nothing else — not the image URL, not verification + * state, not timestamps — so the one place the browser learns about an account carries exactly + * what the account area displays (ADR 0062). + */ +export interface AccountSummary { + readonly name: string; + readonly email: string; + readonly preferences: AccountPreferences; +} + +/** + * A person's own account, and only their own: every method takes the one user id whose account it + * reads or writes, and the HTTP surface supplies it from the authenticated session, never from the + * request. There is no method that lists accounts or reads someone else's by any other key. + */ +export interface AccountStore { + /** The person's summary, or `undefined` when no such person exists. A person who never chose a + * preference gets the defaults. */ + readAccount(userId: EkklesiaUserId): Promise; + /** Changes the named preferences and keeps the rest, returning everything now in effect. */ + updatePreferences( + userId: EkklesiaUserId, + update: AccountPreferencesUpdate, + ): Promise; +} + +function rethrowAsStoreError(error: unknown, code: AccountStoreErrorCode): never { + if (error instanceof AccountStoreError) { + throw error; + } + throw new AccountStoreError(code); +} + +/** + * Core's persistence for a person's own account summary and preferences. + * + * Validation lives here as well as at the HTTP boundary, so the invariants hold for any caller: an + * update is re-read through the same strict parser, and a preference row can only be written for a + * person who exists. + */ +export function createAccountStore(db: AccountDrizzleDatabase): AccountStore { + return Object.freeze({ + async readAccount(userId: EkklesiaUserId): Promise { + try { + const rows = await db + .select({ + appearance: ekklesiaCoreUserPreference.appearance, + email: ekklesiaCoreUser.email, + motion: ekklesiaCoreUserPreference.motion, + name: ekklesiaCoreUser.name, + }) + .from(ekklesiaCoreUser) + .leftJoin( + ekklesiaCoreUserPreference, + eq(ekklesiaCoreUserPreference.userId, ekklesiaCoreUser.id), + ) + .where(eq(ekklesiaCoreUser.id, userId)) + .limit(1); + const row = rows[0]; + if (!row) { + return undefined; + } + return Object.freeze({ + email: row.email, + name: row.name, + preferences: resolveAccountPreferences(row), + }); + } catch (error) { + rethrowAsStoreError(error, "ACCOUNT_READ_FAILED"); + } + }, + + async updatePreferences( + userId: EkklesiaUserId, + update: AccountPreferencesUpdate, + ): Promise { + const parsed = parseAccountPreferencesUpdate(update); + if (parsed === undefined) { + throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID"); + } + try { + return await db.transaction(async (tx) => { + // Checked inside the transaction rather than left to the foreign key, so a person who + // does not exist is a clear "not found" instead of a constraint failure. + const people = await tx + .select({ id: ekklesiaCoreUser.id }) + .from(ekklesiaCoreUser) + .where(eq(ekklesiaCoreUser.id, userId)) + .limit(1); + if (people.length === 0) { + throw new AccountStoreError("ACCOUNT_NOT_FOUND"); + } + const rows = await tx + .insert(ekklesiaCoreUserPreference) + .values({ userId, ...parsed }) + .onConflictDoUpdate({ + set: { ...parsed, updatedAt: sql`now()` }, + target: ekklesiaCoreUserPreference.userId, + }) + .returning({ + appearance: ekklesiaCoreUserPreference.appearance, + motion: ekklesiaCoreUserPreference.motion, + }); + const row = rows[0]; + if (!row) { + throw new AccountStoreError("ACCOUNT_WRITE_FAILED"); + } + return resolveAccountPreferences(row); + }); + } catch (error) { + rethrowAsStoreError(error, "ACCOUNT_WRITE_FAILED"); + } + }, + }); +} diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts index c365db2..9e62204 100644 --- a/core/src/authorization/permissions.ts +++ b/core/src/authorization/permissions.ts @@ -169,6 +169,20 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId */ export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage"); +/** + * Authenticated: every signed-in person may read their own name and email and read and change their + * own preferences (ADR 0062). Choosing dark mode is not church administration, so it needs no + * grant, and a Dashboard where it did would be unusable for its ordinary users. It is a declared + * permission rather than a hand-written "is signed in" check, as `core.modules.ui.read` is + * (ADR 0026), so the account area is authorized by the same evaluator as everything else. + * + * **It is self-only by construction.** The routes it guards take the account from the + * authenticated session and accept no user id at all, so holding it — whether through the baseline + * or a grant — confers no ability to read or change anyone else's account. A device never satisfies + * the `authenticated` baseline: a screen in the foyer has no account to read. + */ +export const CORE_ACCOUNT_SELF_MANAGE: PermissionId = parsePermissionId("core.account.self.manage"); + /** * Core's own declarations, exported in deeply immutable form so a trusted composer (the module * integration layer, `core/src/modules/permissions.ts`) can build one combined registry from @@ -207,6 +221,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob description: "Approve display device pairing, list paired devices, and revoke a device.", id: CORE_DEVICES_MANAGE, }), + Object.freeze({ + baseline: "authenticated" as const, + description: "Read your own account and read and change your own preferences.", + id: CORE_ACCOUNT_SELF_MANAGE, + }), ]); /** diff --git a/core/tests/account-preferences.test.ts b/core/tests/account-preferences.test.ts new file mode 100644 index 0000000..cc21cd2 --- /dev/null +++ b/core/tests/account-preferences.test.ts @@ -0,0 +1,178 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { + ACCOUNT_APPEARANCES, + ACCOUNT_MOTION_PREFERENCES, + ACCOUNT_PREFERENCE_NAMES, + AccountStoreError, + createAccountStore, + DEFAULT_ACCOUNT_PREFERENCES, + ekklesiaCoreUserPreference, + parseAccountPreferencesUpdate, + resolveAccountPreferences, +} from "../src/account/index.js"; + +const migrationsDirectory = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "drizzle", +); + +describe("account preferences model", () => { + it("defaults both preferences to following the system", () => { + expect(DEFAULT_ACCOUNT_PREFERENCES).toEqual({ appearance: "system", motion: "system" }); + expect(Object.isFrozen(DEFAULT_ACCOUNT_PREFERENCES)).toBe(true); + }); + + it("declares a closed, ordered set of names and values", () => { + expect(ACCOUNT_PREFERENCE_NAMES).toEqual(["appearance", "motion"]); + expect(ACCOUNT_APPEARANCES).toEqual(["system", "light", "dark"]); + expect(ACCOUNT_MOTION_PREFERENCES).toEqual(["system", "reduce", "allow"]); + }); + + it.each( + ACCOUNT_APPEARANCES.map((appearance) => [appearance] as const), + )("accepts appearance %s on its own", (appearance) => { + expect(parseAccountPreferencesUpdate({ appearance })).toEqual({ appearance }); + }); + + it.each( + ACCOUNT_MOTION_PREFERENCES.map((motion) => [motion] as const), + )("accepts motion %s on its own", (motion) => { + expect(parseAccountPreferencesUpdate({ motion })).toEqual({ motion }); + }); + + it("accepts both preferences together", () => { + expect(parseAccountPreferencesUpdate({ appearance: "dark", motion: "reduce" })).toEqual({ + appearance: "dark", + motion: "reduce", + }); + }); + + it.each([ + ["an empty object", {}], + ["null", null], + ["an array", [["appearance", "dark"]]], + ["a string", "dark"], + ["an unknown appearance", { appearance: "sepia" }], + ["an unknown motion", { motion: "none" }], + ["a differently cased value", { appearance: "Dark" }], + ["a non-string value", { appearance: 1 }], + ["a null value", { motion: null }], + ["an unknown key beside a valid one", { appearance: "dark", radius: "large" }], + ["an unknown key alone", { theme: "dark" }], + ["a user id", { appearance: "dark", userId: "someone-else" }], + ["a prototype key", JSON.parse('{"__proto__": {"appearance": "dark"}}')], + [ + "a class instance", + new (class Preferences { + appearance = "dark"; + })(), + ], + ])("refuses %s whole", (_label, value) => { + expect(parseAccountPreferencesUpdate(value)).toBeUndefined(); + }); + + it("resolves never-chosen columns to the defaults, and keeps a chosen value", () => { + expect(resolveAccountPreferences({ appearance: null, motion: null })).toEqual( + DEFAULT_ACCOUNT_PREFERENCES, + ); + expect(resolveAccountPreferences({ appearance: "dark", motion: null })).toEqual({ + appearance: "dark", + motion: "system", + }); + // A value the constraint should have made impossible is never passed through. + expect(resolveAccountPreferences({ appearance: "neon", motion: "allow" })).toEqual({ + appearance: "system", + motion: "allow", + }); + }); +}); + +describe("account preference storage", () => { + it("is one row per person, keyed and cascaded by user id, with one typed column per preference", () => { + const config = getTableConfig(ekklesiaCoreUserPreference); + expect(config.name).toBe("ekklesia_core_user_preference"); + expect(config.columns.map((column) => column.name).sort()).toEqual([ + "appearance", + "created_at", + "motion", + "updated_at", + "user_id", + ]); + const userId = config.columns.find((column) => column.name === "user_id"); + expect(userId?.primary).toBe(true); + // Preference columns are nullable: `null` is "never chosen", resolved to the default on read. + for (const name of ACCOUNT_PREFERENCE_NAMES) { + expect(config.columns.find((column) => column.name === name)?.notNull).toBe(false); + } + expect(config.foreignKeys).toHaveLength(1); + expect(config.foreignKeys[0]?.onDelete).toBe("cascade"); + }); + + it("constrains each column in the migration to exactly the model's values", async () => { + const sql = await readFile(path.join(migrationsDirectory, "0008_user_preferences.sql"), "utf8"); + const allowed = (column: string): string[] => { + const match = new RegExp(`"${column}" in \\(([^)]*)\\)`, "u").exec(sql); + return (match?.[1] ?? "").split(",").map((value) => value.trim().replaceAll("'", "")); + }; + expect(allowed("appearance")).toEqual([...ACCOUNT_APPEARANCES]); + expect(allowed("motion")).toEqual([...ACCOUNT_MOTION_PREFERENCES]); + // No JSON, no key/value pair, nothing a client could fill with anything. + expect(sql).not.toMatch(/jsonb?|hstore/iu); + }); + + it("is a migration that destroys nothing", async () => { + const metadata = JSON.parse( + await readFile(path.join(migrationsDirectory, "meta", "_ekklesia.json"), "utf8"), + ) as { entries: { tag: string; destructive: { mode: string } }[] }; + expect(metadata.entries.find((entry) => entry.tag === "0008_user_preferences")).toEqual({ + destructive: { mode: "none" }, + tag: "0008_user_preferences", + }); + }); +}); + +describe("account store validation", () => { + it("refuses an invalid update before touching the database", async () => { + const untouchable = new Proxy( + {}, + { + get() { + throw new Error("the database must not be reached"); + }, + }, + ); + const store = createAccountStore(untouchable as never); + await expect( + store.updatePreferences("user-1" as never, { appearance: "sepia" } as never), + ).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID", name: "AccountStoreError" }); + await expect(store.updatePreferences("user-1" as never, {})).rejects.toBeInstanceOf( + AccountStoreError, + ); + }); + + it("reports a database failure without the underlying error", async () => { + const failing = { + select() { + throw new Error("connect ECONNREFUSED postgres://secret@db:5432"); + }, + transaction() { + throw new Error("connect ECONNREFUSED postgres://secret@db:5432"); + }, + }; + const store = createAccountStore(failing as never); + const read = await store.readAccount("user-1" as never).catch((error: unknown) => error); + expect(read).toBeInstanceOf(AccountStoreError); + expect(read).toMatchObject({ code: "ACCOUNT_READ_FAILED" }); + expect(String((read as Error).stack)).not.toContain("secret"); + const write = await store + .updatePreferences("user-1" as never, { appearance: "dark" }) + .catch((error: unknown) => error); + expect(write).toMatchObject({ code: "ACCOUNT_WRITE_FAILED" }); + expect(String((write as Error).message)).not.toContain("ECONNREFUSED"); + }); +}); diff --git a/core/tests/account-store.integration.ts b/core/tests/account-store.integration.ts new file mode 100644 index 0000000..4dc981e --- /dev/null +++ b/core/tests/account-store.integration.ts @@ -0,0 +1,378 @@ +import { randomBytes } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { EkklesiaUserId } from "@ekklesia/contracts"; +import { Client, type QueryResultRow } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { + type AccountStore, + AccountStoreError, + createAccountDrizzleView, + createAccountStore, +} from "../src/account/index.js"; +import { createValidatedPostgresPool } from "../src/database/index.js"; +import { + applyModuleMigrations, + createPostgresMigrationDatabase, + discoverAllMigrations, +} from "../src/migrations/index.js"; + +/** + * A person's own account summary and preferences against real PostgreSQL (ADR 0062). + * + * The unit suite proves the model and parser; this proves what only a database can: that the + * migration creates the table with its constraints, that a choice survives a completely new + * connection (which is what "follows you to another device" rests on), that one person's change + * never touches another's row, that the database itself refuses a value outside the model, and + * that deleting a person deletes their preferences. + */ +const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; +const coreMigrationsDirectory = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "drizzle", +); +const trackedDatabases = new Set(); +let databaseCounter = 0; + +class IntegrationHarnessError extends Error { + constructor(message: string) { + super(message); + this.name = "IntegrationHarnessError"; + this.stack = `${this.name}: ${this.message}`; + } +} + +function harnessError(message: string): IntegrationHarnessError { + return new IntegrationHarnessError(message); +} + +function baseDatabaseUrl(): string { + const value = process.env[integrationDatabaseUrlEnvironment]; + if (typeof value !== "string" || value.trim() === "") { + throw harnessError("The account integration database is not configured."); + } + try { + const parsed = new URL(value); + if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") { + throw new TypeError("Unsupported protocol."); + } + } catch { + throw harnessError("The account integration database configuration is invalid."); + } + return value; +} + +function quoteDatabaseName(databaseName: string): string { + if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) { + throw harnessError("The generated integration database name is invalid."); + } + return `"${databaseName}"`; +} + +function makeDatabaseName(label: string): string { + const safeLabel = label + .toLowerCase() + .replaceAll(/[^a-z0-9]+/gu, "_") + .replaceAll(/^_+|_+$/gu, "") + .slice(0, 14); + databaseCounter += 1; + return `ekk_acct_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`; +} + +function databaseUrl(databaseName: string): string { + const parsed = new URL(baseDatabaseUrl()); + parsed.pathname = `/${databaseName}`; + parsed.searchParams.delete("application_name"); + return parsed.toString(); +} + +async function openAdministrativeClient(connectionString: string): Promise { + const client = new Client({ connectionString }); + client.on("error", () => undefined); + try { + await client.connect(); + return client; + } catch { + try { + await client.end(); + } catch { + // The public harness failure stays static when connection cleanup also fails. + } + throw harnessError("The account integration database connection failed."); + } +} + +async function safeQuery( + client: Client, + text: string, + values: unknown[] = [], +): Promise { + try { + const result = await client.query(text, values); + return result.rows; + } catch { + throw harnessError("An integration database administration operation failed."); + } +} + +async function safeEnd(client: Client): Promise { + try { + await client.end(); + } catch { + throw harnessError("An integration database connection could not be closed."); + } +} + +async function dropDatabase(databaseName: string): Promise { + const admin = await openAdministrativeClient(baseDatabaseUrl()); + try { + await safeQuery( + admin, + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()", + [databaseName], + ); + await safeQuery( + admin, + `DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`, + ); + trackedDatabases.delete(databaseName); + } finally { + await safeEnd(admin); + } +} + +async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> { + const name = makeDatabaseName(label); + const admin = await openAdministrativeClient(baseDatabaseUrl()); + try { + await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`); + } finally { + await safeEnd(admin); + } + trackedDatabases.add(name); + return { name, url: databaseUrl(name) }; +} + +async function applyCoreMigrations(url: string): Promise { + const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-acct-no-modules-")); + try { + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory: emptyModulesDirectory, + platformVersion: "0.1.0", + }, + }); + expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ + "core:0000_identity_core_tables", + "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", + "core:0003_dashboard_navigation_tables", + "core:0004_module_lifecycle_state", + "core:0005_module_configuration", + "core:0006_device_identity", + "core:0007_verified_bootstrap_owners", + "core:0008_user_preferences", + ]); + await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); + } finally { + await rm(emptyModulesDirectory, { force: true, recursive: true }); + } +} + +afterAll(async () => { + for (const databaseName of [...trackedDatabases]) { + await dropDatabase(databaseName).catch(() => undefined); + } +}); + +const ada = "user-ada" as EkklesiaUserId; +const grace = "user-grace" as EkklesiaUserId; +const nobody = "user-nobody" as EkklesiaUserId; + +async function insertPerson( + client: Client, + id: string, + name: string, + email: string, +): Promise { + await safeQuery( + client, + "insert into ekklesia_core_user (id, name, email, email_verified) values ($1, $2, $3, true)", + [id, name, email], + ); +} + +describe.sequential("live Core account store", () => { + let database: { name: string; url: string }; + let admin: Client; + let store: AccountStore; + let close: () => Promise; + + it("creates the preference table with its constraints", async () => { + database = await createDisposableDatabase("fresh"); + await applyCoreMigrations(database.url); + admin = await openAdministrativeClient(database.url); + + const constraints = await safeQuery<{ constraint_name: string; constraint_type: string }>( + admin, + `select constraint_name, constraint_type from information_schema.table_constraints + where table_name = 'ekklesia_core_user_preference' and constraint_type <> 'CHECK' + or constraint_name like 'ekklesia_core_user_preference_%_check' + order by constraint_name`, + ); + const deleteRules = await safeQuery<{ delete_rule: string }>( + admin, + `select rc.delete_rule from information_schema.referential_constraints rc + join information_schema.table_constraints tc on tc.constraint_name = rc.constraint_name + where tc.table_name = 'ekklesia_core_user_preference'`, + ); + + expect(constraints.map((constraint) => constraint.constraint_name)).toEqual([ + "ekklesia_core_user_preference_appearance_check", + "ekklesia_core_user_preference_motion_check", + "ekklesia_core_user_preference_pkey", + "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk", + ]); + expect(deleteRules.map((rule) => rule.delete_rule)).toEqual(["CASCADE"]); + + await insertPerson(admin, ada, "Ada Lovelace", "ada@example.test"); + await insertPerson(admin, grace, "Grace Hopper", "grace@example.test"); + + const handle = createValidatedPostgresPool(database.url); + close = handle.close; + store = createAccountStore(createAccountDrizzleView(handle.pool)); + }); + + it("reads a person who never chose anything with their name, email, and the defaults", async () => { + expect(await store.readAccount(ada)).toEqual({ + email: "ada@example.test", + name: "Ada Lovelace", + preferences: { appearance: "system", motion: "system" }, + }); + const rows = await safeQuery(admin, "select * from ekklesia_core_user_preference"); + // Reading never materializes a row nobody chose. + expect(rows).toHaveLength(0); + }); + + it("answers undefined for a person who does not exist", async () => { + expect(await store.readAccount(nobody)).toBeUndefined(); + }); + + it.each([ + ["appearance", "light"], + ["appearance", "dark"], + ["appearance", "system"], + ["motion", "reduce"], + ["motion", "allow"], + ["motion", "system"], + ] as const)("saves %s %s and reads it back", async (name, value) => { + const saved = await store.updatePreferences(ada, { [name]: value }); + expect(saved[name]).toBe(value); + expect((await store.readAccount(ada))?.preferences[name]).toBe(value); + }); + + it("changes only the preference named, keeping the other", async () => { + await store.updatePreferences(ada, { appearance: "dark", motion: "reduce" }); + expect(await store.updatePreferences(ada, { motion: "allow" })).toEqual({ + appearance: "dark", + motion: "allow", + }); + expect(await store.updatePreferences(ada, { appearance: "light" })).toEqual({ + appearance: "light", + motion: "allow", + }); + }); + + it("stores a first partial choice with the other preference still never chosen", async () => { + expect(await store.updatePreferences(grace, { motion: "reduce" })).toEqual({ + appearance: "system", + motion: "reduce", + }); + const rows = await safeQuery<{ appearance: string | null; motion: string | null }>( + admin, + "select appearance, motion from ekklesia_core_user_preference where user_id = $1", + [grace], + ); + expect(rows).toEqual([{ appearance: null, motion: "reduce" }]); + }); + + it("keeps each person's preferences to themselves", async () => { + await store.updatePreferences(ada, { appearance: "dark", motion: "allow" }); + await store.updatePreferences(grace, { appearance: "light", motion: "reduce" }); + expect((await store.readAccount(ada))?.preferences).toEqual({ + appearance: "dark", + motion: "allow", + }); + expect(await store.readAccount(grace)).toEqual({ + email: "grace@example.test", + name: "Grace Hopper", + preferences: { appearance: "light", motion: "reduce" }, + }); + }); + + it("survives a completely new connection, as another device signing in would see", async () => { + const reopened = createValidatedPostgresPool(database.url); + try { + const other = createAccountStore(createAccountDrizzleView(reopened.pool)); + expect((await other.readAccount(ada))?.preferences).toEqual({ + appearance: "dark", + motion: "allow", + }); + } finally { + await reopened.close(); + } + }); + + it("refuses to store preferences for a person who does not exist", async () => { + const failure = await store + .updatePreferences(nobody, { appearance: "dark" }) + .catch((error: unknown) => error); + expect(failure).toBeInstanceOf(AccountStoreError); + expect(failure).toMatchObject({ code: "ACCOUNT_NOT_FOUND" }); + const rows = await safeQuery( + admin, + "select 1 from ekklesia_core_user_preference where user_id = $1", + [nobody], + ); + expect(rows).toHaveLength(0); + }); + + it("refuses an invalid update with nothing written", async () => { + await expect( + store.updatePreferences(ada, { appearance: "sepia" } as never), + ).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID" }); + expect((await store.readAccount(ada))?.preferences.appearance).toBe("dark"); + }); + + it("has the database itself refuse a value outside the model", async () => { + await expect( + admin.query( + "update ekklesia_core_user_preference set appearance = 'sepia' where user_id = $1", + [ada], + ), + ).rejects.toMatchObject({ code: "23514" }); + await expect( + admin.query("update ekklesia_core_user_preference set motion = 'none' where user_id = $1", [ + ada, + ]), + ).rejects.toMatchObject({ code: "23514" }); + }); + + it("deletes a person's preferences with the person", async () => { + await safeQuery(admin, "delete from ekklesia_core_user where id = $1", [grace]); + const rows = await safeQuery( + admin, + "select 1 from ekklesia_core_user_preference where user_id = $1", + [grace], + ); + expect(rows).toHaveLength(0); + expect(await store.readAccount(grace)).toBeUndefined(); + await safeEnd(admin); + await close(); + }); +}); diff --git a/core/tests/authorization-permissions.test.ts b/core/tests/authorization-permissions.test.ts index 97b8b51..07b2851 100644 --- a/core/tests/authorization-permissions.test.ts +++ b/core/tests/authorization-permissions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE, + CORE_ACCOUNT_SELF_MANAGE, CORE_DEVICES_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, @@ -133,7 +134,13 @@ describe("CORE_PERMISSION_REGISTRY", () => { expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE); }); - it("contains exactly these six permissions and no others", () => { + it("registers core.account.self.manage as authenticated: managing your own account needs no grant", () => { + const declaration = CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE); + expect(declaration?.baseline).toBe("authenticated"); + expect(declaration?.id).toBe("core.account.self.manage"); + }); + + it("contains exactly these seven permissions and no others", () => { expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([ CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_MODULES_UI_READ, @@ -141,6 +148,7 @@ describe("CORE_PERMISSION_REGISTRY", () => { CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE, CORE_DEVICES_MANAGE, + CORE_ACCOUNT_SELF_MANAGE, ]); }); }); @@ -178,6 +186,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => { description: "Approve display device pairing, list paired devices, and revoke a device.", id: CORE_DEVICES_MANAGE, }, + { + baseline: "authenticated", + description: "Read your own account and read and change your own preferences.", + id: CORE_ACCOUNT_SELF_MANAGE, + }, ]); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true); for (const declaration of CORE_PERMISSION_DECLARATIONS) { diff --git a/core/tests/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts index 240b01c..9f3a79f 100644 --- a/core/tests/authorization-store.integration.ts +++ b/core/tests/authorization-store.integration.ts @@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0005_module_configuration", "core:0006_device_identity", "core:0007_verified_bootstrap_owners", + "core:0008_user_preferences", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -243,6 +244,8 @@ describe.sequential("live Core authorization store", () => { "ekklesia_core_platform_owner_bootstrap", "ekklesia_core_session", "ekklesia_core_user", + // A person's own preferences, one typed column each (ADR 0062). + "ekklesia_core_user_preference", "ekklesia_core_verification", ]); diff --git a/core/tests/dashboard-store.integration.ts b/core/tests/dashboard-store.integration.ts index 393dc58..5af6c9f 100644 --- a/core/tests/dashboard-store.integration.ts +++ b/core/tests/dashboard-store.integration.ts @@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0005_module_configuration", "core:0006_device_identity", "core:0007_verified_bootstrap_owners", + "core:0008_user_preferences", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index 688cf3e..92f839c 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -92,7 +92,7 @@ Modules ✓ Dependency graph valid Dependency order is empty. ✓ Permission registry composed - 6 permissions registered + 7 permissions registered ○ Module lifecycle skipped DATABASE_URL is not configured. ○ Module configuration skipped diff --git a/core/tests/identity-verification-boundaries.test.ts b/core/tests/identity-verification-boundaries.test.ts index 0ad01dc..f7339f7 100644 --- a/core/tests/identity-verification-boundaries.test.ts +++ b/core/tests/identity-verification-boundaries.test.ts @@ -170,12 +170,14 @@ describe("the browser shell reaches none of it", () => { it("never puts a reset token in browser storage", async () => { // A reset token is a bearer credential for one account; the shell holds it in React state for - // the life of one form and nowhere else. The shell's one use of browser storage is the desktop - // sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is - // named only by the entry point, and only to hand it to the sidebar preference, whose one write - // is its own fixed key and a validated mode. + // the life of one form and nowhere else. The shell's uses of browser storage are pinned + // exactly: the desktop sidebar's expanded/collapsed mode (ADR 0058) and the pre-paint copy of + // the person's appearance (ADR 0062). `localStorage` is named only by the entry point, once, to + // hand to those two, and by the pre-paint script, once, to read its one key. Each write is its + // own fixed key and a validated value. const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src")); const writes: string[] = []; + const removals: string[] = []; for (const file of files) { const code = withoutComments(file.text); expect(code, file.path).not.toContain("sessionStorage"); @@ -185,18 +187,32 @@ describe("the browser shell reaches none of it", () => { (match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`, ), ); + removals.push( + ...[...code.matchAll(/\.removeItem\(([^)]*)\)/gu)].map( + (match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`, + ), + ); if (file.path.endsWith(path.join("browser", "main.tsx"))) { expect(code.match(/localStorage/gu), file.path).toHaveLength(1); expect(code).toMatch( - /createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u, + /const browserStorage = \(\(\) => \{\s*try \{\s*return window\.localStorage;/u, ); + expect(code).toContain("createLocalSidebarPreference(browserStorage)"); + expect(code).toContain("createLocalAppearanceCache(browserStorage)"); + } else if (file.path.endsWith(path.join("browser", "appearance-boot.ts"))) { + expect(code.match(/localStorage/gu), file.path).toHaveLength(1); + expect(code).toContain("window.localStorage.getItem(APPEARANCE_CACHE_KEY)"); } else { expect(code, file.path).not.toContain("localStorage"); } } expect(writes).toEqual([ + `${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY, serialized`, `${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`, ]); + expect(removals).toEqual([ + `${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY`, + ]); // The files that ever hold the token touch no storage API at all. for (const file of files.filter((candidate) => ["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) => diff --git a/core/tests/module-configuration-store.integration.ts b/core/tests/module-configuration-store.integration.ts index a3d0d84..f2e6179 100644 --- a/core/tests/module-configuration-store.integration.ts +++ b/core/tests/module-configuration-store.integration.ts @@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0005_module_configuration", "core:0006_device_identity", "core:0007_verified_bootstrap_owners", + "core:0008_user_preferences", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/module-lifecycle-store.integration.ts b/core/tests/module-lifecycle-store.integration.ts index f1985ee..68348e0 100644 --- a/core/tests/module-lifecycle-store.integration.ts +++ b/core/tests/module-lifecycle-store.integration.ts @@ -182,6 +182,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0005_module_configuration", "core:0006_device_identity", "core:0007_verified_bootstrap_owners", + "core:0008_user_preferences", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/module-permissions.test.ts b/core/tests/module-permissions.test.ts index 69ebdda..7706ee0 100644 --- a/core/tests/module-permissions.test.ts +++ b/core/tests/module-permissions.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE, + CORE_ACCOUNT_SELF_MANAGE, CORE_DEVICES_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, @@ -75,8 +76,9 @@ describe("composePlatformPermissionRegistry", () => { expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true); expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true); expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true); - // Core's own five declarations plus one from each module. - expect(registry.declarations).toHaveLength(8); + expect(registry.has(CORE_ACCOUNT_SELF_MANAGE)).toBe(true); + // Core's own seven declarations plus one from each module. + expect(registry.declarations).toHaveLength(9); }); it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => { @@ -97,6 +99,7 @@ describe("composePlatformPermissionRegistry", () => { CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE, CORE_DEVICES_MANAGE, + CORE_ACCOUNT_SELF_MANAGE, "calendar.events.read", "calendar.events.write", "people.directory.read", diff --git a/docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md b/docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md new file mode 100644 index 0000000..da9e5ec --- /dev/null +++ b/docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md @@ -0,0 +1,199 @@ +# 0062: The account area, and appearance preferences that belong to the account + +- Status: Accepted +- Date: 2026-10-14 +- Extends: [0026](0026-minimal-ekklesia-module-ui-host.md), + [0044](0044-self-hosted-reference-deployment.md), + [0045](0045-split-dashboard-and-api-origins.md), + [0057](0057-dashboard-urls-are-host-owned-routes.md), + [0058](0058-dashboard-shell-navigation-and-lucide-icons.md), + [0060](0060-light-dark-system-theme-and-motion.md), + [0061](0061-settings-hierarchy-and-module-settings-routes.md) + +## Context + +DESIGN.md puts the signed-in person at the bottom of the sidebar (§4.5), keeps personal configuration +under that account area rather than in church-wide Settings (§9.2), and requires Light, Dark, and +System appearance with the person's choice **syncing across their devices** (§3.2, locked decision +25), System being the default (locked decision 26). + +ADR 0060 built the theme: the token stylesheet resolves appearance and motion in CSS, a controller sets +two attributes on the theme root, and `main.tsx` is the bootstrap hook where a remembered choice would +be supplied. It deferred the Account → Appearance page and synced storage, and it noted the remaining +cost of a page with no inline script: an explicit choice opposite to the system can show the system's +empty canvas until the module script runs. + +Three things were missing. The shell knew the person only as an opaque user id +(`/api/v1/principal`), so the account control could not say who was signed in. There was nowhere to +keep a personal preference. And the Dashboard page is one frozen snapshot with one `ETag`, served in +the split topology from a hostname that never receives the API's session cookie (ADR 0045), so the +server cannot write a person's choice into the HTML. + +## Decision + +### The account area + +```text +/account Account: who you are signed in as, and your own settings +/account/appearance Appearance: Light, Dark, or System; and reduced motion +``` + +Personal, never church administration. It needs no management permission, it sits outside the Settings +hierarchy (ADR 0061), and the sidebar's Settings group does not expand on it. `account` was already a +reserved top-level name (ADR 0057), so the API already served the page here and no server or proxy +change was needed for the addresses. Account sections live beneath `/account/`, so they cannot collide +with a module id and are not reserved. Anything else under `/account/` is *Page not found* until it +exists. + +**The account control names the person**: their initials, their name, and their email, at the bottom +of the sidebar and in the phone drawer. Its menu offers exactly the destinations that work: Account, +Appearance, and Sign out. Account and Appearance are real links. When the account cannot be loaded, the +control falls back to "Your account" and keeps every destination. + +**No avatar image.** The account's `image` is an arbitrary URL the platform has never vetted. +Rendering it would make every Dashboard visit a request to whatever host it names, and it has no CSP +story. Initials are drawn from the name instead, or from the email when the name is blank. Showing a +person's picture needs an upload and serving path the platform owns, which is later work. + +There is **no profile editing**. Name and email are shown as Better Auth stores them, and nothing on +the page pretends to be editable. + +### Preferences belong to the account + +One Core table, one row per person, **one typed column per preference**: + +```text +ekklesia_core_user_preference + user_id text primary key, references ekklesia_core_user(id) on delete cascade + appearance text null, or one of 'system' | 'light' | 'dark' (CHECK) + motion text null, or one of 'system' | 'reduce' | 'allow' (CHECK) + created_at, updated_at +``` + +- **`null` means never chosen** and resolves to the default (System, System) when read. A column + default would make "chose System" indistinguishable from "never chose". It would also force a value + nobody chose onto every existing row when a preference is added. A person with no row has chosen + nothing. Reading never creates a row. +- **A new preference is a new nullable column** in a new migration, beside these. That is not one + table per preference, and it is not a key/value or JSON document a client could fill with anything. + Values are checked twice: by Core's strict parser (`parseAccountPreferencesUpdate`) and by the + database's `CHECK` constraints. +- Core declares the value sets itself (`ACCOUNT_APPEARANCES`, `ACCOUNT_MOTION_PREFERENCES`), because + Core cannot import the design tokens. An `apps/api` test holds them equal to `EKKLESIA_APPEARANCES` + and `EKKLESIA_MOTION_PREFERENCES`. +- Migration `core:0008_user_preferences` creates the table and destroys nothing. Like every migration, + it is applied only by an operator (`pnpm migrations:apply`). + +The sidebar's expanded or collapsed mode is **not** moved here. It stays a preference of this browser +on this device (ADR 0058). + +### The API is self-only by construction + +| Route | Body | Answer | +| --- | --- | --- | +| `GET /api/v1/account` | none | `{ name, email, preferences: { appearance, motion } }` | +| `PATCH /api/v1/account/preferences` | `{ appearance?, motion? }`, at least one | `{ appearance, motion }`, everything now in effect | + +- **Neither route accepts a user id**, not in the path, the query, or the body. The account is always + the one the session names, so no request can read or change anyone else's, whatever the caller holds. + A body naming any key besides a known preference, or any value outside its set, is a `400` with + nothing changed. +- **`PATCH` changes only what it names.** Changing motion on a phone never overwrites an appearance just + chosen on a laptop. +- **Both require `core.account.self.manage`**, a new Core permission with an `authenticated` baseline. + Every signed-in person holds it, and choosing dark mode needs no grant. It is a declared permission + rather than a hand-written "is signed in" check, following `core.modules.ui.read` (ADR 0026). A paired + device never satisfies `authenticated`, and an anonymous caller is refused; both get `403`. The route + also checks that the principal is a person before reading. +- The mutation takes an exact trusted `Origin` and a bounded body before authorization, like every Core + mutation. Responses are `no-store` and vary by `Origin, Cookie`. +- **The browser learns name, email, and preferences, and nothing else**: not the image URL, + verification state, timestamps, or user id. + +### First paint: a classic pre-paint script, never inline + +The server cannot render the choice into the page. The page is one snapshot with one validator for +everyone, and in the split topology its hostname never sees the session cookie. So: + +1. The page loads `/assets/ekklesia-dashboard/appearance.js` in ``, after + ``. It is a classic, same-origin, render-blocking script of about a + kilobyte, and **never inline**, so a strict `script-src` still needs no exception. ADR 0060 rejected + an inline script for exactly that reason. It is a fixed asset route beside `app.js` with its own + strong `ETag`. The proxies already forward `/assets/ekklesia-dashboard/*`. +2. It reads this browser's **cached copy** of the choice: one key, `ekklesia.dashboard.appearance`, + holding two words (for example `dark reduce`). It sets the two theme attributes on the root. For an + explicit Light or Dark it also narrows the colour-scheme element, so the browser's canvas is painted + in that scheme before the token stylesheet arrives. An invalid, missing, or unreadable cache does + nothing, and the page follows the system. +3. `main.tsx` starts the ADR 0060 controller from the same cache, so the two agree. + +**The cache is never the source of truth.** Every Dashboard load replaces it with what the account +holds, so a choice made on another device takes effect here from the next load on. A choice is saved to +the account first-class: it applies at once, the account's answer is what stays applied, and a failed +save puts the account's value back and says so. The cache holds nothing about the account. It is +cleared on sign-out and whenever the page resolves to signed-out, so the next person to use the browser +starts from the system's setting. An account that cannot be loaded leaves the cache alone rather than +guessing. + +This removes the cost ADR 0060 noted. An explicit choice is in place from the first frame on any browser +that has loaded the Dashboard before. A first visit, a new device, or a blocked storage follows the +system until the account answers, moments later. + +### Changes apply live, and other tabs follow + +A choice takes effect through `setAppearance` and `setMotion` on the one controller, with no reload and +no Save button. Isolated module roots follow because the attributes and custom properties inherit, as in +ADR 0060. + +**Other tabs** of the Dashboard in the same browser follow through the browser's `storage` event on the +cache key, with no request. Sign-out in one tab returns the others to the system as well. +**Cross-device realtime is not provided**: another device picks up a change on its next load. That meets +DESIGN.md's "syncs across devices" without a push channel, which would cost a persistent connection per +open Dashboard for a setting people change rarely. + +### Unchanged + +- **The display host stays Light** (ADR 0060). It loads neither the pre-paint script nor the account. + A lobby screen is not a person. +- Module UIs read the theme through tokens and never set it, and the module UI contract is unchanged. +- No church theme, brand colours, radius control, administrator-enforced mode, or Signage appearance. + +## Alternatives + +- **Render the choice into the HTML on the server.** Rejected: one frozen snapshot and `ETag` for + everyone (ADR 0044), and in the split topology the Dashboard hostname never receives the session + cookie (ADR 0045). Per-person HTML would also put account data into a cacheable page. +- **An inline bootstrap script.** Rejected, as in ADR 0060: it costs a strict `script-src` an exception. + A same-origin external script does the same job. +- **Treat `localStorage` as the preference store.** Rejected: it does not follow the person to another + device, and it would survive into the next person's session on a shared computer. +- **A cookie carrying the choice.** Rejected: in the split topology a cookie the Dashboard origin could + read would be a second, cross-origin cookie policy for one preference. It would also travel on every + request. +- **A key/value or JSON preference table.** Rejected: values the server cannot type-check, and a column + any client could fill. One typed, constrained column per preference costs one migration per + preference, which is the point. +- **One table per preference.** Rejected: many tables for one row per person. +- **Fold the account into `/api/v1/principal`.** Rejected: the principal is the platform's identity + primitive, also read by the display host and the module UI host. Name and email are the account area's + concern, behind their own permission. +- **Load the account's `image`.** Rejected, as above. +- **Realtime cross-device sync.** Deferred: next-load reconciliation meets the requirement. +- **Move the sidebar mode to the account.** Not done: it is a per-device preference (ADR 0058). + +## Consequences + +- Core gains `@ekklesia/core/account` (schema, store, value sets), migration `0008_user_preferences`, + and a seventh Core permission, `core.account.self.manage`. Every composed registry and the Doctor's + count grow by one. +- The Dashboard serves three host-owned files instead of two, and bootstrap makes one more request, + alongside the navigation and Settings. An account that fails to load never stops the Dashboard. +- Browser storage now holds two things: the sidebar mode and the appearance cache. A boundary test pins + each read and write. +- No public `@ekklesia/*` package changes. + +## Deferred + +Profile editing, an uploaded avatar the platform serves itself, notification and other personal +preferences, which arrive as further columns. Also deferred: realtime cross-device sync, a church theme +or administrator-enforced appearance, and the display host's own appearance. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 6c8cdd2..a48aed4 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -70,3 +70,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted - [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted - [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted +- [0062: The account area, and appearance preferences that belong to the account](0062-account-area-and-synced-appearance-preferences.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 7b55c50..3b1bec1 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -607,7 +607,14 @@ hierarchy in the same sidebar: a landing page, the platform's General, Branding, `/settings/`. Which of them a person sees is evaluated by the server (`GET /api/v1/dashboard/settings`) as presentation hints, while every route behind them keeps its own enforcement; the Settings section names are reserved module ids -([ADR 0061](decisions/0061-settings-hierarchy-and-module-settings-routes.md)). `pnpm boundaries` opens +([ADR 0061](decisions/0061-settings-hierarchy-and-module-settings-routes.md)). Personal configuration +lives apart from it, in the account area: `/account` shows who is signed in, and `/account/appearance` +holds Light, Dark, or System and reduced motion. These preferences belong to the account, stored by +Core one typed column per preference and served only to their owner (`GET /api/v1/account`, +`PATCH /api/v1/account/preferences`, `core.account.self.manage`), so they follow the person to every +device. A small same-origin script in the page's `` applies this browser's cached copy before +first paint, and the account's answer replaces it on every load +([ADR 0062](decisions/0062-account-area-and-synced-appearance-preferences.md)). `pnpm boundaries` opens exactly two shared roles to modules **by name**, and pins both design packages beneath everything else. The Dashboard and display install the defaults at their document root; custom properties inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at diff --git a/docs/development/api.md b/docs/development/api.md index bcebaac..7e37003 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -79,7 +79,8 @@ No real values are shown here — configure per environment and never commit the | `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. | | `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. | | `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). | -| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). | +| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's application script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). | +| `GET /assets/ekklesia-dashboard/appearance.js` | Ekklesia | The Dashboard's small pre-paint appearance script, loaded in ``. `no-cache` with its own strong `ETag`. See [Dashboard host](#dashboard-host) and [Account](#account). | | `GET /api/v1/health/live` | Ekklesia | Unauthenticated liveness: fixed `200 {"status":"live"}` whenever the process is serving HTTP. `Cache-Control: no-store`; GET only. See [Health](#health). | | `GET /api/v1/health/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). | | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). | @@ -87,6 +88,8 @@ No real values are shown here — configure per environment and never commit the | `GET /api/v1/module-realtime//` | Installed modules | Snapshot-first Server-Sent Events for one registered module-local channel, gated by that channel's declared permission. See [Module realtime](#module-realtime). | | `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's HTTP and realtime roots live, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). | | `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). | +| `GET /api/v1/account` | Ekklesia | The signed-in person's own name, email, and preferences. Requires `core.account.self.manage` (baseline `authenticated`). Takes no user id. See [Account](#account). | +| `PATCH /api/v1/account/preferences` | Ekklesia | Change some of the signed-in person's own preferences. Requires `core.account.self.manage` and a trusted `Origin`. See [Account](#account). | | `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). | | `GET /api/v1/module-ui//` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). | | `GET /api/v1/module-ui///display` | Ekklesia | Publishes the exact validated display artifact for that eligible/running module. Artifact bytes are anonymous trusted code; this route is not the authorization boundary. See [Module display host](#module-display-host). | @@ -147,14 +150,16 @@ same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBO unset, the session cookie stays host-only, and no second static server or proxy file root is needed. In the split topology ([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards -the Dashboard's own hostname to the same two routes; the API does not inspect `Host`. It works +the Dashboard's own hostname to the same routes; the API does not inspect `Host`. It works exactly like the display host: - **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the - exact URLs of two built files, `dist/browser/index.html` and - `dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any - module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256 - strong `ETag` for each. + exact URLs of three built files: `dist/browser/index.html`, + `dist/browser/assets/ekklesia-dashboard/app.js`, and the pre-paint + `dist/browser/assets/ekklesia-dashboard/appearance.js` + ([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)). + `startApi()` reads all three before importing any module code or binding the listener, and keeps + one frozen snapshot of their text with a SHA-256 strong `ETag` for each. - **The API's origin is written in at startup.** The built page carries one empty ``. Before computing the page's `ETag`, `startApi()` fills it with the identity base URL's origin, which is where `/api` lives in either topology. The @@ -166,33 +171,35 @@ exactly like the display host: `DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources already opened. It never carries a path or the filesystem error. Build the shell first; `pnpm api:start` already runs the full build. -- **Two exact routes, never a directory.** `GET /` answers the page and - `GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and +- **Exact routes, never a directory.** `GET /` (and the Dashboard's own pages, ADR 0057) answers + the page, `GET /assets/ekklesia-dashboard/app.js` the application script, and + `GET /assets/ekklesia-dashboard/appearance.js` the pre-paint script. No request value selects a + file, and nothing touches the filesystem while answering. Every other path, including `/index.html`, `/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary `404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client router, and emailed links land on `/` with a query (see [The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's implicit `HEAD`) are also the ordinary `404`. -- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two - files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive +- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the + served files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive a `404` for it. Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's CORS policy does not apply. The bytes grant no authority: the page renders sign-in until `/api/v1/principal` says otherwise. -| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` | +| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` and `appearance.js` | | --- | --- | --- | | `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` | | `Cache-Control` | `no-cache` | `no-cache` | -| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script | +| `ETag` | strong SHA-256 of the page | strong SHA-256 of each script | | `X-Content-Type-Options` | `nosniff` | `nosniff` | | `X-Frame-Options` | `DENY` | — | | `Content-Security-Policy` | `frame-ancestors 'none'` | — | | `Referrer-Policy` | `no-referrer` | — | -**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every +**Caching.** The shell emits fixed, unhashed file names, so every file is revalidated on every load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match` (strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and `Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry @@ -1218,6 +1225,47 @@ platform-global; Ekklesia has no organization or tenant model to scope it to yet Responses are per-principal, so `Cache-Control: no-store` and `Vary: Origin, Cookie`. +## Account + +The signed-in person's own account +([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)): who +they are signed in as, and their personal preferences, which follow them to every device. + +| Route | Body | `200` answer | +| --- | --- | --- | +| `GET /api/v1/account` | none | `{"name":"Ada Lovelace","email":"ada@example.test","preferences":{"appearance":"dark","motion":"system"}}` | +| `PATCH /api/v1/account/preferences` | `{"appearance":"light"}`, `{"motion":"reduce"}`, or both | `{"appearance":"light","motion":"system"}`, everything now in effect | + +- **Self-only by construction.** Neither route accepts a user id in the path, query, or body. The + account is always the session's, so no request can read or change anyone else's. +- **Values.** `appearance` is one of `system`, `light`, or `dark`. `motion` is one of `system`, + `reduce`, or `allow`. A person who never chose gets `system` for both. +- **`PATCH` is partial.** It changes only the preferences it names and keeps the rest, so two devices + changing different preferences never overwrite each other. A body that is empty, names any other key, + or uses any other value is refused whole with `400 {"code":"INVALID_REQUEST"}`, and nothing is + written. +- **What it returns.** Name and email exactly as the account holds them, and nothing else about the + account: no image URL, verification state, timestamps, or user id. +- **Authority.** Both routes require `core.account.self.manage`, whose baseline is `authenticated`, so + every signed-in person may use them with no grant. An anonymous caller or a paired device gets + `403 {"code":"AUTHORIZATION_DENIED"}`. The `PATCH` also needs an exact trusted `Origin` + (`403 {"code":"UNTRUSTED_ORIGIN"}`) and a body within the Core mutation limit (`413`), both checked + before authorization. +- **Failures.** A person whose session outlived their account gets `404 {"code":"ACCOUNT_NOT_FOUND"}`. + A storage failure is a sanitized `503` (`ACCOUNT_READ_FAILED` or `ACCOUNT_WRITE_FAILED`). +- **Headers.** Responses are `Cache-Control: no-store` and `Vary: Origin, Cookie`. + +**Storage.** One row per person in `ekklesia_core_user_preference`, with one typed, `CHECK`-constrained +column per preference. `null` means never chosen, and deleting the person deletes the row. It is created +by migration `core:0008_user_preferences`, which an operator applies with `pnpm migrations:apply`. Until +it is applied, the account routes answer a sanitized `503`, and the rest of the Dashboard keeps working. + +**In the browser.** The Dashboard loads the account at bootstrap and applies its preferences through +the ADR 0060 theme controller. It keeps a two-word copy (`ekklesia.dashboard.appearance` in +`localStorage`) for one purpose: so `appearance.js` can apply the choice before the first paint. That +copy is never the source of truth. It is replaced by the account's answer on every load, cleared on +sign-out, and followed by other open tabs through the browser's `storage` event. + ## Module UI inventory ```text diff --git a/docs/development/authorization.md b/docs/development/authorization.md index 2f732ac..99bb6fb 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -112,6 +112,8 @@ list: | `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. | | `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. | | `core.modules.configuration.manage` | `restricted` | Read and change an installed module's own configuration values. | +| `core.devices.manage` | `restricted` | Approve display device pairing, list paired devices, and revoke a device. | +| `core.account.self.manage` | `authenticated` | Read your own account and read and change your own preferences. | Every Core permission is a **platform** permission and can never be declared, gated, or influenced by a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both @@ -122,6 +124,13 @@ milder version of the write, because the lifecycle view is the only place a *dis `core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority inside any module. +`core.account.self.manage` is the one Core permission every signed-in person holds by baseline +besides `core.modules.ui.read`: choosing your own appearance is not church administration +([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)). It is +**self-only by construction** rather than by a check: the account routes take the account from the +session and accept no user id at all, so the permission, however it is held, confers nothing over +anyone else's account. A device never satisfies its `authenticated` baseline. + `core.modules.configuration.manage` is deliberately **separate from** the lifecycle permission ([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)): deciding whether a module runs and deciding how it behaves are different authorities, and someone trusted to tune a @@ -235,7 +244,7 @@ generic explicit-grant HTTP API, enforcement middleware, and now its own mounted `pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the same already-discovered module list `modules.discovery`/`modules.dependencies` already produced — it never re-discovers modules itself. A successful composition reports a pass with a safe -declaration count (for example `5 permissions registered` with zero installed modules, since Core's +declaration count (for example `7 permissions registered` with zero installed modules, since Core's own declarations are always present); zero installed modules still composes successfully. If module discovery itself failed, the check is skipped, matching the existing schema/migration checks' pattern; if discovery succeeded but composition failed, schema and migration checks still run diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index 51b1d98..b778fe1 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -136,7 +136,7 @@ Modules ✓ Dependency graph valid Dependency order is empty. ✓ Permission registry composed - 6 permissions registered + 7 permissions registered ○ Module lifecycle skipped DATABASE_URL is not configured. ○ Module configuration skipped @@ -146,7 +146,7 @@ Data definitions ✓ Schemas validated 0 modules · 0 tables ✓ Migrations validated - 7 migrations · checksums valid + 9 migrations · checksums valid core:0000_identity_core_tables · f8f2086ef06f core:0001_authorization_core_tables · 830e1fbc26d0 core:0002_authorization_integrity · 634294731180 @@ -154,6 +154,8 @@ Data definitions core:0004_module_lifecycle_state · c8b5d8b3d48b core:0005_module_configuration · c8d57f24f84b core:0006_device_identity · 1db97b7e3ac5 + core:0007_verified_bootstrap_owners · 49013062f3e0 + core:0008_user_preferences · fa9d4b0bb9d3 PostgreSQL ✓ Database TLS verified diff --git a/docs/development/security.md b/docs/development/security.md index b832b45..922ce27 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -425,6 +425,28 @@ enforced by `requirePermission` against the composed platform permission registr enforcement neither knows nor cares what a browser decided. Hiding a control is a usability improvement, never an access control. Treat any claim that a UI check protects anything as a bug. +### A person's account is theirs alone, and the browser's copy of it is not authority + +The account area +([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)) +returns a signed-in person's name, email, and preferences, and lets them change their preferences. It +is **self-only by construction**: `GET /api/v1/account` and `PATCH /api/v1/account/preferences` take +the account from the session and accept no user id anywhere, so no request can name another person. +Both routes require `core.account.self.manage` (baseline `authenticated`), which no device satisfies. +The mutation requires an exact trusted `Origin` and a bounded body, like every Core mutation. + +- **Nothing more leaves the server.** The account's image URL, verification state, timestamps, and + user id are never returned. +- **No avatar image is loaded.** The account's `image` is an arbitrary, unvetted URL, and fetching it + would disclose every Dashboard visit to whatever host it names. Initials are drawn instead. +- **Nothing about the account is in the HTML.** The Dashboard page is still one snapshot for everyone. + The pre-paint `appearance.js` is a same-origin external script, so the page still has no inline + script and a strict `script-src` needs no exception. +- **The browser keeps two words.** It stores the appearance and motion words under + `ekklesia.dashboard.appearance` for the next first paint. That copy holds nothing about the account, + is validated strictly before use, is replaced by the account's answer on every load, and is cleared + on sign-out. Tampering with it changes nothing but this browser's colours until the account answers. + ## What this platform must not claim Ekklesia today has **none** of the following, and no document, README, or marketing surface may diff --git a/docs/development/testing.md b/docs/development/testing.md index 2912186..59ad72c 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -404,9 +404,36 @@ no runtime assertion can make: that the identity feature names no SMTP detail, t schema gained a token column and no identity code hashes or compares a token, that nothing interpolates a token, link, address, or body into a log line, that a device has no email, password, or verification concept, that no browser source imports Better Auth or Core's mail, and that the -shell's only use of browser storage is the sidebar's expanded/collapsed mode — `localStorage` named -only by the entry point, one write of one fixed key, and no storage API in any file that holds the -reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). +shell's uses of browser storage are exactly two: the sidebar's expanded/collapsed mode +([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)) and the +pre-paint appearance cache +([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)). +`localStorage` is named once by the entry point and once, read-only, by the pre-paint script. There is +one write of each fixed key, one removal of the appearance key, and no storage API in any file that +holds the reset token. + +The account area +([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)) is +proven at each layer: + +- `core/tests/account-preferences.test.ts` covers the closed preference model, its strict parser, the + table's typed columns, and that the migration's `CHECK` lists equal the model. +- `core/tests/account-store.integration.ts` uses real PostgreSQL. It proves defaults without writing a + row, saving each value, partial updates, isolation between two people, persistence across a new + connection, the database refusing an out-of-model value, and the cascade on delete. +- `apps/api/tests/account-routes.test.ts` covers the routes. Anonymous and device callers are refused, + only the session's account is ever read or written whatever the request names, there is no route + that addresses another person, and validation, `Origin`, body limit, failures, and CORS preflight + all behave as specified. It also holds Core's value sets equal to the design tokens'. +- `apps/api/tests/api-http.integration.ts` runs two real accounts over real HTTP sessions, with the + same isolation proven end to end. +- `surfaces/shell/tests/appearance-preference.test.ts` covers the cache, the pre-paint script (run in + the DOM), and the one handle over the ADR 0060 controller. +- `surfaces/shell/tests/account-appearance.test.tsx` covers the account control's identity, both + pages, live switching, saving, reverting on failure, reconciling with the account, cross-tab + following, sign-out forgetting, and the phone drawer. +- The deploy smoke's real Chromium checks Light and Dark rendering, reload persistence, an emulated OS + change, reduced motion, a module's Shadow DOM following the theme, and phone width. Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an injected in-memory provider and never touch the network. They prove the parts the protocol cannot: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8b7304..e272699 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ importers: '@biomejs/biome': specifier: 2.5.3 version: 2.5.3 + '@ekklesia/design-tokens': + specifier: workspace:* + version: link:../../packages/design-tokens '@ekklesia/module-ui-host': specifier: workspace:* version: link:../../packages/module-ui-host diff --git a/scripts/lib/deploy-smoke-browser.mjs b/scripts/lib/deploy-smoke-browser.mjs index 887633c..46c0cb3 100644 --- a/scripts/lib/deploy-smoke-browser.mjs +++ b/scripts/lib/deploy-smoke-browser.mjs @@ -102,10 +102,27 @@ const send = (method, params) => cdp.send(method, params, sessionId); * `loadingFailed` here, never a response. */ const responses = []; const failed = []; +/** Requests the page has started and not finished, by id: what a timed-out wait was stuck behind. + * Only paths are ever printed, never a query, header, or body. */ +const inflight = new Map(); +/** Every request's URL by id, so a failure can be named after the request it ended. */ +const inflightUrls = new Map(); cdp.on((message) => { if (message.sessionId !== sessionId) { return; } + if (message.method === "Network.requestWillBeSent") { + inflightUrls.set(message.params.requestId, message.params.request.url); + inflight.set(message.params.requestId, { + type: message.params.type, + url: message.params.request.url, + }); + } else if ( + message.method === "Network.loadingFinished" || + message.method === "Network.loadingFailed" + ) { + inflight.delete(message.params.requestId); + } if (message.method === "Network.responseReceived") { const { response, type } = message.params; responses.push({ status: response.status, type, url: response.url }); @@ -145,6 +162,37 @@ async function waitFor(expression, description) { await new Promise((resolve) => setTimeout(resolve, 250)); } console.log(`FAIL timed out waiting for ${description}`); + const page = await evaluate( + `({ path: location.pathname, ready: document.readyState, shell: Boolean(document.querySelector('[data-testid="host-shell"]')), loading: Boolean(document.querySelector('[data-testid="host-loading"]')), error: Boolean(document.querySelector('[data-testid="host-error"], [data-testid="module-error"]')) })`, + ).catch(() => "unavailable"); + const pending = [...inflight.values()].map((request) => { + try { + const url = new URL(request.url); + return `${request.type} ${url.origin === apiOrigin ? "api" : url.origin === dashboardOrigin ? "dashboard" : "other"}${url.pathname}`; + } catch { + return request.type; + } + }); + const shortPath = (value) => { + try { + const url = new URL(value); + return `${url.origin === apiOrigin ? "api" : url.origin === dashboardOrigin ? "dashboard" : "other"}${url.pathname}`; + } catch { + return "unparseable"; + } + }; + // The last few refused or failed requests: which one broke the page, and how. + const refusedRecently = responses + .filter((response) => response.status >= 400) + .slice(-6) + .map((response) => `${response.status} ${shortPath(response.url)}`); + const failedRecently = failed + .slice(-6) + .map( + (failure) => + `${failure.errorText}${failure.corsErrorStatus ? ` cors:${failure.corsErrorStatus.corsError}` : ""} ${shortPath(inflightUrls.get(failure.requestId) ?? "")}`, + ); + console.log(`info ${JSON.stringify({ failedRecently, page, pending, refusedRecently })}`); failures += 1; return false; } @@ -421,6 +469,208 @@ check( "leaving Settings returns to the normal Dashboard navigation", ); +// --- Account and Appearance (ADR 0062): identity, live switching, persistence, first paint ------ +const emulate = (colorScheme, reducedMotion) => + send("Emulation.setEmulatedMedia", { + features: [ + { name: "prefers-color-scheme", value: colorScheme }, + { name: "prefers-reduced-motion", value: reducedMotion }, + ], + }); +const rootScheme = "getComputedStyle(document.documentElement).colorScheme"; +const rootToken = (name) => + `getComputedStyle(document.documentElement).getPropertyValue("--ekklesia-${name}").trim()`; +const option = (group, value) => + `document.querySelector('[data-testid="appearance-${group}-${value}"] input')`; +const savedPreferences = () => + responses.filter( + (response) => + response.url === `${apiOrigin}/api/v1/account/preferences` && response.status === 200, + ).length; +/** + * Chooses one option on the Appearance page and waits until the account has saved it. An option + * already chosen needs no save: a radio that is already checked does not change. + */ +async function choose(group, value) { + if (await evaluate(`Boolean(${option(group, value)}?.checked)`)) { + return true; + } + const before = savedPreferences(); + await evaluate(`${option(group, value)}.click()`); + const deadline = Date.now() + timeoutMs; + while (savedPreferences() <= before && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return ( + savedPreferences() > before && + (await waitFor( + `${option(group, value)}?.checked && ${selector("appearance-status")}?.textContent.includes("Saved")`, + `${group} ${value} to be saved`, + )) + ); +} + +await emulate("light", "no-preference"); +await navigate(`${dashboardOrigin}/`); +await waitFor(selector("host-account-menu"), "the account control"); +check( + await waitFor( + `${selector("host-account-menu")}.textContent.includes(${JSON.stringify(required("SMOKE_OWNER_EMAIL"))}) && ${selector("host-account-menu")}.querySelector('[data-testid="account-avatar"]')`, + "the account control to name the signed-in person", + ), + "the account control shows who is signed in, with their initials", +); +await evaluate(`${selector("host-account-menu")}.click()`); +await waitFor(selector("host-appearance-link"), "Appearance in the account menu"); +await evaluate(`${selector("host-appearance-link")}.click()`); +check( + await waitFor( + `location.pathname === "/account/appearance" && ${selector("account-appearance")}`, + "the Appearance page", + ), + "Appearance opens from the account menu at /account/appearance", +); +// Start from System for both, whatever an earlier pass left on this account. +check( + (await choose("appearance", "system")) && (await choose("motion", "system")), + "System appearance and System motion save to the account", +); +const lightBackground = await evaluate(`getComputedStyle(document.body).backgroundColor`); +await emulate("dark", "no-preference"); +check( + await waitFor(`${rootScheme} === "dark"`, "System to follow a dark operating system"), + "with System chosen, an operating system switching to dark restyles the page live", +); +await emulate("light", "no-preference"); +check( + await waitFor(`${rootScheme} === "light"`, "System to follow a light operating system"), + "and switching back to light restyles it again, with no reload", +); +check(await choose("appearance", "dark"), "choosing Dark saves it to the account"); +check( + await waitFor( + `${rootScheme} === "dark" && getComputedStyle(document.body).backgroundColor !== ${JSON.stringify(lightBackground)}`, + "Dark to take effect under a light operating system", + ), + "Dark takes effect at once, overriding a light operating system", +); +await navigate(`${dashboardOrigin}/account/appearance`); +check( + await waitFor( + `${selector("account-appearance")} && ${option("appearance", "dark")}?.checked && ${rootScheme} === "dark" && localStorage.getItem("ekklesia.dashboard.appearance") === "dark system"`, + "Dark after a reload", + ), + "a reload keeps Dark: the account holds it, and this browser cached two words for first paint", +); +// First paint without the application: only the pre-paint script runs, and it already applies Dark. +await send("Network.setBlockedURLs", { urls: ["*/assets/ekklesia-dashboard/app.js"] }); +await navigate(`${dashboardOrigin}/`); +check( + await waitFor( + `document.readyState === "complete" && !${selector("host-shell")} && document.documentElement.getAttribute("data-ekklesia-appearance") === "dark" && document.querySelector('meta[name="color-scheme"]').content === "dark"`, + "the pre-paint script alone to apply Dark", + ), + "before the application script runs at all, the pre-paint script has applied Dark", +); +await send("Network.setBlockedURLs", { urls: [] }); +if (openedModuleId) { + const moduleScheme = `(() => { + const host = document.querySelector("[data-ekklesia-module-ui]"); + const inner = host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]"); + if (!inner) return null; + const style = getComputedStyle(inner); + return { page: style.getPropertyValue("--ekklesia-background-page").trim(), scheme: style.colorScheme }; + })()`; + await navigate(`${dashboardOrigin}/${openedModuleId}`); + await waitFor( + `${selector("module-container")}?.childElementCount > 0 && (${moduleScheme})?.scheme === "dark"`, + "the module's isolated root in Dark", + ); + const darkModule = await evaluate(moduleScheme); + check( + darkModule?.scheme === "dark" && + darkModule.page === (await evaluate(rootToken("background-page"))), + `the ${openedModuleId} module's Shadow DOM root follows Dark through the tokens`, + ); + await navigate(`${dashboardOrigin}/account/appearance`); + await waitFor(selector("account-appearance"), "the Appearance page"); + await emulate("dark", "no-preference"); + check(await choose("appearance", "light"), "choosing Light saves it to the account"); + check( + await waitFor(`${rootScheme} === "light"`, "Light under a dark operating system"), + "Light takes effect at once, overriding a dark operating system", + ); + await navigate(`${dashboardOrigin}/${openedModuleId}`); + await waitFor( + `${selector("module-container")}?.childElementCount > 0 && (${moduleScheme})?.scheme === "light"`, + "the module's isolated root in Light", + ); + const lightModule = await evaluate(moduleScheme); + check( + lightModule?.scheme === "light" && lightModule.page !== darkModule?.page, + `the ${openedModuleId} module's Shadow DOM root follows Light too`, + ); + await navigate(`${dashboardOrigin}/account/appearance`); + await waitFor(selector("account-appearance"), "the Appearance page"); +} +// Reduced motion: System follows the operating system; Allow and Reduce override it. +const shortDuration = rootToken("motion-duration-short"); +await emulate("light", "reduce"); +check( + await waitFor(`${shortDuration} === "0ms"`, "System motion to follow a reduce request"), + "with System motion, an operating system asking for less motion zeroes durations", +); +check(await choose("motion", "allow"), "choosing Allow saves it to the account"); +check( + await waitFor(`${shortDuration} !== "0ms"`, "Allow under a reduce request"), + "Allow keeps the platform's subtle motion even when the system asks for less", +); +await emulate("light", "no-preference"); +check(await choose("motion", "reduce"), "choosing Reduce saves it to the account"); +check( + await waitFor(`${shortDuration} === "0ms"`, "Reduce with no system request"), + "Reduce zeroes motion even when the system asks for none", +); +if (displayPath !== null) { + // The display host is a lobby screen, not a person: it stays Light whatever the OS or the account. + check(await choose("appearance", "dark"), "Dark is chosen again before visiting a display"); + await emulate("dark", "no-preference"); + await navigate(`${apiOrigin}${displayPath}`); + check( + await waitFor( + `${selector("display-module-container")}?.childElementCount > 0 && ${rootScheme} === "light"`, + "the display to stay Light", + ), + "the display page stays Light under a dark operating system and a Dark account choice", + ); + await emulate("light", "no-preference"); +} +await send("Emulation.setDeviceMetricsOverride", { + deviceScaleFactor: 2, + height: 844, + mobile: true, + width: 390, +}); +await navigate(`${dashboardOrigin}/account/appearance`); +check( + await waitFor( + `${selector("account-appearance")} && document.documentElement.scrollWidth <= window.innerWidth && [...document.querySelectorAll(".ek-shell-choice")].length === 6 && [...document.querySelectorAll(".ek-shell-choice")].every((element) => element.getBoundingClientRect().height >= 44)`, + "the Appearance page at phone width", + ), + "at phone width the Appearance page fits without sideways scrolling, every option a 44px+ target", +); +check( + (await choose("appearance", "system")) && (await choose("motion", "system")), + "the account is put back to System for both", +); +await send("Emulation.setDeviceMetricsOverride", { + deviceScaleFactor: 1, + height: 1080, + mobile: false, + width: 1920, +}); +await send("Emulation.setEmulatedMedia", { features: [] }); + // --- Sign out on the Dashboard, from the account menu at the bottom of the sidebar --------------- await navigate(`${dashboardOrigin}/`); await waitFor(selector("host-account-menu"), "the account control"); @@ -435,6 +685,12 @@ const principal = await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/pr credentials: "include", }).then((response) => response.json()).then((body) => body.kind)`); check(principal === "anonymous", "after sign-out, the API answers anonymous to the same browser"); +check( + await evaluate( + `localStorage.getItem("ekklesia.dashboard.appearance") === null && document.documentElement.getAttribute("data-ekklesia-appearance") === "system"`, + ), + "sign-out forgets this browser's cached appearance, and the page follows the system", +); await cdp.send("Target.closeTarget", { targetId }); cdp.close(); diff --git a/scripts/lib/deploy-smoke-client.mjs b/scripts/lib/deploy-smoke-client.mjs index 13dccbc..50336d8 100644 --- a/scripts/lib/deploy-smoke-client.mjs +++ b/scripts/lib/deploy-smoke-client.mjs @@ -73,8 +73,9 @@ async function edgeChecks() { check( page.headers.get("content-type") === "text/html; charset=utf-8" && html.includes('id="ekklesia-host"') && - html.includes('src="/assets/ekklesia-dashboard/app.js"'), - "GET / is the Dashboard HTML", + html.includes('src="/assets/ekklesia-dashboard/app.js"') && + html.includes(''), + "GET / is the Dashboard HTML, with its pre-paint appearance script (ADR 0062)", ); check( page.headers.get("x-frame-options") === "DENY" && @@ -90,7 +91,12 @@ async function edgeChecks() { // Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module // is installed yet at this point; a module's own address is reloaded in a real browser later. - for (const deepLink of ["/settings", "/settings/displays/add", "/account"]) { + for (const deepLink of [ + "/settings", + "/settings/displays/add", + "/account", + "/account/appearance", + ]) { const deepPage = await fetch(`${dashboardOrigin}${deepLink}`); check( deepPage.status === 200 && @@ -113,6 +119,17 @@ async function edgeChecks() { await script.arrayBuffer(); check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200"); + const appearanceScript = await fetch( + `${dashboardOrigin}/assets/ekklesia-dashboard/appearance.js`, + ); + const appearanceSource = await appearanceScript.text(); + check( + appearanceScript.status === 200 && + appearanceScript.headers.get("content-type") === "text/javascript; charset=utf-8" && + appearanceSource.includes("ekklesia.dashboard.appearance"), + "GET /assets/ekklesia-dashboard/appearance.js is the pre-paint script", + ); + const outside = await fetch(`${dashboardOrigin}/favicon.ico`); check( outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"), @@ -181,6 +198,10 @@ async function splitRoutingChecks() { await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`), "the API hostname refuses the Dashboard's script", ); + check( + await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/appearance.js`), + "the API hostname refuses the Dashboard's pre-paint script", + ); const display = await fetch(`${apiOrigin}/display/does-not-exist`); check( display.status === 404 && (await display.text()) === '{"code":"NOT_FOUND"}', @@ -460,6 +481,38 @@ async function sessionChecks() { "the session resolves an authenticated principal through the proxy", ); + // The person's own account (ADR 0062): who they are, and a preference that round-trips. + const account = await fetch(`${apiOrigin}/api/v1/account`, { + headers: { cookie, origin: dashboardOrigin }, + }); + const accountBody = await account.json(); + check( + account.status === 200 && + accountBody.email === required("SMOKE_OWNER_EMAIL") && + Object.keys(accountBody).sort().join(",") === "email,name,preferences", + "GET /api/v1/account returns the signed-in person's own name, email, and preferences only", + ); + const changePreferences = async (body) => { + const response = await fetch(`${apiOrigin}/api/v1/account/preferences`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json", cookie, origin: dashboardOrigin }, + method: "PATCH", + }); + return { body: await response.json(), status: response.status }; + }; + const saved = await changePreferences({ motion: "reduce" }); + check( + saved.status === 200 && saved.body.motion === "reduce", + "PATCH /api/v1/account/preferences saves a change to the person's own account", + ); + const restored = await changePreferences(accountBody.preferences); + check(restored.status === 200, "the account's previous preferences are put back"); + const anonymousAccount = await fetch(`${apiOrigin}/api/v1/account`, { + headers: { origin: dashboardOrigin }, + }); + await anonymousAccount.arrayBuffer(); + check(anonymousAccount.status === 403, "an anonymous caller is refused the account"); + const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, { body: "{}", headers: { "content-type": "application/json", cookie, origin: dashboardOrigin }, diff --git a/scripts/smoke-deploy.sh b/scripts/smoke-deploy.sh index 7db170e..82bd451 100755 --- a/scripts/smoke-deploy.sh +++ b/scripts/smoke-deploy.sh @@ -607,8 +607,11 @@ if [[ "$topology" == split ]]; then docker rm --force "$browser_container" >/dev/null 2>&1 || true # The image's entry script passes its arguments on unquoted, so none may contain a space: the # hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule. + # Docker's default 64 MiB /dev/shm is too small for one tab that loads the Dashboard, a module, + # and its live streams dozens of times: Chromium then fails requests with + # ERR_INSUFFICIENT_RESOURCES partway through. A real desktop has far more. docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \ - --cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp --shm-size 512m \ --network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \ "$browser_image" \ "--ignore-certificate-errors-spki-list=${spki}" \ diff --git a/surfaces/shell/index.html b/surfaces/shell/index.html index f1437cc..28abacd 100644 --- a/surfaces/shell/index.html +++ b/surfaces/shell/index.html @@ -10,6 +10,11 @@ + + Ekklesia ` runs before the body is parsed and painted. Built + * separately as an IIFE, with nothing shared with the main bundle at run time, so it stays a few + * hundred bytes and adds no import to a render-blocking request. + */ +/** @type {import("esbuild").BuildOptions} */ +const appearanceBootOptions = { + ...buildOptions, + entryPoints: { + "assets/ekklesia-dashboard/appearance": path.join( + packageDirectory, + "src", + "browser", + "appearance-boot.ts", + ), + }, + format: "iife", + sourcemap: false, +}; + await rm(outputDirectory, { force: true, recursive: true }); await mkdir(outputDirectory, { recursive: true }); await mkdir(path.join(outputDirectory, "display"), { recursive: true }); @@ -87,6 +108,8 @@ await copyFile( ); if (serve) { + const appearanceBootContext = await esbuild.context(appearanceBootOptions); + await appearanceBootContext.watch(); const context = await esbuild.context(buildOptions); await context.watch(); // Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as @@ -104,5 +127,6 @@ if (serve) { ); } else { await esbuild.build(buildOptions); + await esbuild.build(appearanceBootOptions); console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`); } diff --git a/surfaces/shell/src/browser/AccountAvatar.tsx b/surfaces/shell/src/browser/AccountAvatar.tsx new file mode 100644 index 0000000..bbc0805 --- /dev/null +++ b/surfaces/shell/src/browser/AccountAvatar.tsx @@ -0,0 +1,83 @@ +import { designToken } from "@ekklesia/design-tokens"; +import type { ReactElement } from "react"; +import type { HostAccount } from "../host-protocol.js"; + +/** The first user-perceived character of a word, so an accented letter or an emoji is never cut in + * half. */ +function firstCharacter(word: string): string { + if (typeof Intl.Segmenter === "function") { + for (const { segment } of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment( + word, + )) { + return segment; + } + return ""; + } + return Array.from(word)[0] ?? ""; +} + +/** + * A person's initials, from the name they signed up with: the first letter of the first and last + * words (`Ada King Lovelace` → `AL`), one letter for a one-word name, and the email's first letter + * when the name is blank. + */ +export function accountInitials(account: Pick): string { + const words = account.name + .trim() + .split(/\s+/u) + .filter((word) => word.length > 0); + const first = words[0]; + const last = words.length > 1 ? words.at(-1) : undefined; + if (first === undefined) { + return firstCharacter(account.email.trim()).toLocaleUpperCase(); + } + return `${firstCharacter(first)}${last === undefined ? "" : firstCharacter(last)}`.toLocaleUpperCase(); +} + +/** The name to show for a person: what they signed up with, or their email if that is blank. */ +export function accountDisplayName(account: Pick): string { + const name = account.name.trim(); + return name.length > 0 ? name : account.email; +} + +/** + * A person's initials in a circle (DESIGN.md §4.5). + * + * Initials rather than the account's `image`: that is an arbitrary URL the platform has never + * vetted, and loading it would send every Dashboard visit to whatever host it names (ADR 0062). + * Decorative — the person's name is always beside it or in the control's accessible name — so it is + * hidden from assistive technology. Text on the subtle accent is a contrast-tested pair in both + * schemes (`@ekklesia/design-tokens` `tests/contrast.test.ts`). + */ +export function AccountAvatar(props: { + readonly account: Pick; + readonly size?: "md" | "lg"; +}): ReactElement { + const size = props.size === "lg" ? "3rem" : "1.75rem"; + return ( + + ); +} diff --git a/surfaces/shell/src/browser/AccountMenu.tsx b/surfaces/shell/src/browser/AccountMenu.tsx index e8c00c6..ff375a7 100644 --- a/surfaces/shell/src/browser/AccountMenu.tsx +++ b/surfaces/shell/src/browser/AccountMenu.tsx @@ -7,22 +7,43 @@ import { useRef, useState, } from "react"; +import type { DashboardDestination } from "../dashboard-routes.js"; +import type { HostAccount } from "../host-protocol.js"; +import { AccountAvatar, accountDisplayName } from "./AccountAvatar.js"; +import { DashboardLink } from "./DashboardLink.js"; +import type { HostHistory } from "./host-history.js"; import { useNavTooltip } from "./NavTooltip.js"; import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js"; import { ShellIcon, type ShellIconName } from "./shell-icons.js"; export interface AccountMenuProps { readonly presentation: NavigationPresentation; + readonly history: HostHistory; + /** Who is signed in, or `"unavailable"` when their account could not be loaded: the control then + * names the area rather than the person, and still offers every destination. */ + readonly account: HostAccount | "unavailable"; readonly onSignOut: () => void; + /** Called after choosing a destination, so a drawer around the menu can close. */ + readonly onNavigate?: () => void; } -interface AccountAction { - readonly id: string; - readonly label: string; - readonly icon: ShellIconName; - readonly testId: string; - readonly run: () => void; -} +type AccountAction = + | { + readonly kind: "link"; + readonly id: string; + readonly label: string; + readonly icon: ShellIconName; + readonly testId: string; + readonly to: DashboardDestination; + } + | { + readonly kind: "button"; + readonly id: string; + readonly label: string; + readonly icon: ShellIconName; + readonly testId: string; + readonly run: () => void; + }; /** * The signed-in person's account control, at the bottom of the navigation (DESIGN.md §4.5). @@ -33,21 +54,23 @@ interface AccountAction { * returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it * went. * - * It offers **only actions that work today** — Sign out. Profile, personal preferences, and - * appearance belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages; until then there - * is no dead link to `/account`. The shell knows the person only by an opaque user id - * (`/api/v1/principal`), which is never shown, so the control names the area rather than the person. + * It shows **who is signed in** — their name and email beside their initials (ADR 0062) — and + * offers only destinations that work: Account (`/account`), Appearance (`/account/appearance`), and + * Sign out. Account and Appearance are real links, so they open in a new tab like any other. When + * the account cannot be loaded, the control names the area ("Your account") rather than the person. */ export function AccountMenu(props: AccountMenuProps): ReactElement { const [open, setOpen] = useState(false); const [initialFocus, setInitialFocus] = useState<"first" | "last">("first"); const wrapperRef = useRef(null); const triggerRef = useRef(null); - const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + const itemRefs = useRef<(HTMLElement | null)[]>([]); const menuId = useId(); const triggerId = useId(); const { collapsed } = props.presentation; - const { tooltip, triggerProps } = useNavTooltip("Your account", collapsed && !open); + const account = props.account === "unavailable" ? undefined : props.account; + const name = account ? accountDisplayName(account) : "Your account"; + const { tooltip, triggerProps } = useNavTooltip(name, collapsed && !open); const close = useCallback((returnFocus: boolean) => { setOpen(false); @@ -57,9 +80,26 @@ export function AccountMenu(props: AccountMenuProps): ReactElement { }, []); const actions: readonly AccountAction[] = [ + { + icon: "account", + id: "account", + kind: "link", + label: "Account", + testId: "host-account-link", + to: { kind: "account" }, + }, + { + icon: "appearance", + id: "appearance", + kind: "link", + label: "Appearance", + testId: "host-appearance-link", + to: { kind: "account-section", section: "appearance" }, + }, { icon: "signOut", id: "sign-out", + kind: "button", label: "Sign out", run: () => { setOpen(false); @@ -78,7 +118,7 @@ export function AccountMenu(props: AccountMenuProps): ReactElement { if (!open) { return; } - const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null); + const items = itemRefs.current.filter((item): item is HTMLElement => item !== null); (initialFocus === "last" ? items.at(-1) : items[0])?.focus(); }, [initialFocus, open]); @@ -98,8 +138,8 @@ export function AccountMenu(props: AccountMenuProps): ReactElement { }, [open]); const onMenuKeyDown = (event: KeyboardEvent): void => { - const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null); - const index = items.indexOf(event.target as HTMLButtonElement); + const items = itemRefs.current.filter((item): item is HTMLElement => item !== null); + const index = items.indexOf(event.target as HTMLElement); const focusAt = (next: number): void => { event.preventDefault(); items[(next + items.length) % items.length]?.focus(); @@ -125,32 +165,68 @@ export function AccountMenu(props: AccountMenuProps): ReactElement { return (
{open ? ( -
) : undefined} {tooltip} diff --git a/surfaces/shell/src/browser/AccountPages.tsx b/surfaces/shell/src/browser/AccountPages.tsx new file mode 100644 index 0000000..7420fa4 --- /dev/null +++ b/surfaces/shell/src/browser/AccountPages.tsx @@ -0,0 +1,359 @@ +import { designToken } from "@ekklesia/design-tokens"; +import { Button, ErrorState, InlineNotice, PageHeader, Panel } from "@ekklesia/ui"; +import { + type ReactElement, + useCallback, + useEffect, + useId, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import type { DashboardDestination } from "../dashboard-routes.js"; +import { + type EkklesiaHostConfig, + type HostAccount, + type HostAccountPreferences, + HostProtocolError, + saveHostAccountPreferences, +} from "../host-protocol.js"; +import { AccountAvatar, accountDisplayName } from "./AccountAvatar.js"; +import type { AppearancePreference } from "./appearance-preference.js"; +import { dashboardBack } from "./dashboard-back.js"; +import { DashboardLink } from "./DashboardLink.js"; +import type { HostHistory } from "./host-history.js"; +import { ShellIcon } from "./shell-icons.js"; + +/** + * The account area (DESIGN.md §9.2, ADR 0062): who you are signed in as, and your own settings. + * + * Personal, never church administration — that is Settings (ADR 0061). Nothing here needs a + * management permission, and nothing here can affect anyone else. + */ + +const ACCOUNT: DashboardDestination = { kind: "account" }; +const APPEARANCE: DashboardDestination = { kind: "account-section", section: "appearance" }; + +/** + * `/account`: the person's own account. Their name and email as they signed up with them, and the + * personal settings they can change. There is no profile editing yet, so nothing here pretends to + * be editable. + */ +export function AccountHomePage(props: { + readonly account: HostAccount; + readonly history: HostHistory; + readonly onSignOut: () => void; +}): ReactElement { + const { account } = props; + const hasName = account.name.trim().length > 0; + return ( +
+ + +
+ +
+ {hasName ? ( +
+
Name
+
+ {accountDisplayName(account)} +
+
+ ) : undefined} +
+
Email
+
+ {account.email} +
+
+
+
+
+ +
    +
  • + + + + Appearance + + Light, dark, or matching your device, and how much the Dashboard moves. + + + +
  • +
+
+
+ +
+
+ ); +} + +interface ChoiceOption { + readonly value: Value; + readonly label: string; + readonly description: string; +} + +const APPEARANCE_OPTIONS: readonly ChoiceOption[] = [ + { description: "Match your device's light or dark setting.", label: "System", value: "system" }, + { description: "Always use the light appearance.", label: "Light", value: "light" }, + { description: "Always use the dark appearance.", label: "Dark", value: "dark" }, +]; + +const MOTION_OPTIONS: readonly ChoiceOption[] = [ + { + description: "Reduce motion when your device asks for less.", + label: "System", + value: "system", + }, + { description: "Always keep animation to a minimum.", label: "Reduce", value: "reduce" }, + { + description: "Keep the Dashboard's subtle animation, even if your device asks for less.", + label: "Allow", + value: "allow", + }, +]; + +/** + * One preference as a native radio group: a `
` named by its ``, each option a real + * `` with its label and description. Arrow keys move between options, as the + * browser already does for a radio group, and the chosen option is marked by the radio itself and by + * weight, never by colour alone. Each row is a full-width touch target. + */ +function ChoiceGroup(props: { + readonly name: string; + readonly legend: string; + readonly description: string; + readonly options: readonly ChoiceOption[]; + readonly value: Value; + readonly onChange: (value: Value) => void; +}): ReactElement { + const descriptionId = useId(); + return ( +
+ {props.legend} +

+ {props.description} +

+
+ {props.options.map((option) => { + const checked = option.value === props.value; + return ( + + ); + })} +
+
+ ); +} + +type SaveStatus = + | { readonly kind: "idle" } + | { readonly kind: "saving" } + | { readonly kind: "saved" } + | { readonly kind: "failed" }; + +/** + * `/account/appearance`: Appearance (System, Light, Dark) and Motion (System, Reduce, Allow). + * + * A choice takes effect **at once**, with no reload and no Save button, and is saved to the account + * so it follows the person to every device they sign in on. The account is the source of truth: + * what the server answers is what the shell applies, and if saving fails, the choice is put back to + * what the account still holds, with a message saying so. Other open tabs of this Dashboard follow + * along through this browser's cache. + */ +export function AppearancePage(props: { + readonly account: HostAccount; + readonly appearance: AppearancePreference; + readonly config: EkklesiaHostConfig; + readonly history: HostHistory; + readonly onSaved: (preferences: HostAccountPreferences) => void; + readonly onSessionCheck: () => void; + readonly returnTarget?: unknown; +}): ReactElement { + const { appearance, config, onSaved, onSessionCheck } = props; + const subscribe = useCallback( + (listener: () => void) => appearance.subscribe(listener), + [appearance], + ); + const read = useCallback(() => appearance.current(), [appearance]); + const current = useSyncExternalStore(subscribe, read, read); + const [status, setStatus] = useState({ kind: "idle" }); + // What the account last confirmed: the value a failed save is put back to. + const confirmed = useRef(props.account.preferences); + const sequence = useRef(0); + const inFlight = useRef(new Set()); + + useEffect(() => { + confirmed.current = props.account.preferences; + }, [props.account.preferences]); + + useEffect(() => { + const controllers = inFlight.current; + return () => { + for (const controller of controllers) { + controller.abort(); + } + }; + }, []); + + const choose = (update: Partial): void => { + appearance.apply({ ...appearance.current(), ...update }); + sequence.current += 1; + const mine = sequence.current; + const controller = new AbortController(); + inFlight.current.add(controller); + setStatus({ kind: "saving" }); + void saveHostAccountPreferences(config, update, controller.signal) + .then((saved) => { + confirmed.current = saved; + onSaved(saved); + if (mine === sequence.current) { + appearance.apply(saved); + setStatus({ kind: "saved" }); + } + }) + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; + } + if (mine === sequence.current) { + appearance.apply(confirmed.current); + setStatus({ kind: "failed" }); + } + if (error instanceof HostProtocolError && error.code === "HOST_UNAUTHENTICATED") { + // The session may have ended: the shell checks once, and signs the page out if so. + onSessionCheck(); + } + }) + .finally(() => { + inFlight.current.delete(controller); + }); + }; + + return ( +
+ (destination.kind === "account" ? "Back to Account" : "Back"), + returnTarget: props.returnTarget, + })} + description="How the Dashboard looks for you, on every device you sign in on." + title="Appearance" + /> + +
+ choose({ appearance: value })} + options={APPEARANCE_OPTIONS} + value={current.appearance} + /> + choose({ motion: value })} + options={MOTION_OPTIONS} + value={current.motion} + /> +
+
+
+ {status.kind === "saved" ? ( + Saved to your account. + ) : undefined} + {status.kind === "failed" ? ( + + It's back to what your account had. Check your connection and try again. + + ) : undefined} +
+
+ ); +} + +/** The account could not be loaded: the rest of the Dashboard still works, and Retry asks again. */ +export function AccountLoadFailedPage(props: { readonly onRetry: () => void }): ReactElement { + return ( +
+ Retry} + description="The rest of the Dashboard still works. Try again in a moment." + title="Your account could not be loaded" + /> +
+ ); +} diff --git a/surfaces/shell/src/browser/DashboardLink.tsx b/surfaces/shell/src/browser/DashboardLink.tsx index c251af6..7a14be8 100644 --- a/surfaces/shell/src/browser/DashboardLink.tsx +++ b/surfaces/shell/src/browser/DashboardLink.tsx @@ -7,6 +7,7 @@ import type { MouseEventHandler, ReactElement, ReactNode, + Ref, } from "react"; import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js"; import type { HostHistory } from "./host-history.js"; @@ -29,6 +30,11 @@ export interface DashboardLinkProps { readonly onMouseEnter?: MouseEventHandler; readonly onMouseLeave?: MouseEventHandler; readonly "data-testid"?: string; + /** For a link that is an item of a menu (the account menu): its role, its roving `tabIndex`, and + * a ref so the menu can move focus to it. */ + readonly role?: "menuitem"; + readonly tabIndex?: number; + readonly ref?: Ref; } const variantClass = { @@ -100,7 +106,10 @@ export function DashboardLink(props: DashboardLinkProps): ReactElement { onKeyDown={props.onKeyDown} onMouseEnter={props.onMouseEnter} onMouseLeave={props.onMouseLeave} + ref={props.ref} + role={props.role} style={props.style ? { ...baseStyle, ...props.style } : baseStyle} + tabIndex={props.tabIndex} > {props.children} diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx index f0bd058..b7705fd 100644 --- a/surfaces/shell/src/browser/HostApp.tsx +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -25,10 +25,17 @@ import { bootstrapHost, type DashboardNavigationEntry, type EkklesiaHostConfig, + type HostAccountPreferences, type HostBootstrap, HostProtocolError, loadHostPrincipal, } from "../host-protocol.js"; +import { AccountHomePage, AccountLoadFailedPage, AppearancePage } from "./AccountPages.js"; +import { createMemoryAppearanceCache } from "./appearance-cache.js"; +import { + type AppearancePreference, + createDetachedAppearancePreference, +} from "./appearance-preference.js"; import { ForgotPasswordForm } from "./ForgotPasswordForm.js"; import type { HostHistory } from "./host-history.js"; import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js"; @@ -142,6 +149,13 @@ export interface HostAppProps { * passes this browser's storage; left unset, the mode lasts only as long as this component. */ readonly sidebarPreference?: SidebarPreference; + /** + * The signed-in person's appearance and motion as this page applies them (ADR 0062). The entry + * point passes one over the document's theme controller and this browser's pre-paint cache; left + * unset, choices apply to nothing and last only as long as this component. Must be a stable + * reference. + */ + readonly appearance?: AppearancePreference; /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */ readonly importModule?: ModuleUiImporter; } @@ -278,8 +292,12 @@ export function HostApp(props: HostAppProps): ReactElement { // Fallbacks for a composition that supplies neither; created once, so they stay stable. const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined)); const [fallbackPreference] = useState(() => createMemorySidebarPreference()); + const [fallbackAppearance] = useState(() => + createDetachedAppearancePreference(createMemoryAppearanceCache()), + ); const viewport = props.viewport ?? fallbackViewport; const sidebarPreference = props.sidebarPreference ?? fallbackPreference; + const appearance = props.appearance ?? fallbackAppearance; const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]); const readPathname = useCallback(() => history.pathname(), [history]); const pathname = useSyncExternalStore(subscribe, readPathname, readPathname); @@ -308,8 +326,38 @@ export function HostApp(props: HostAppProps): ReactElement { [], ); - const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => { - setState(resolved); + /** + * Every resolved bootstrap reconciles the page's appearance with the account (ADR 0062): the + * account's preferences replace whatever this browser cached, so a choice made on another device + * is in effect here from this load on. A signed-out page follows the system and keeps no cached + * choice, so the next person to use this browser does not inherit one. An account that could not + * be loaded leaves the cached choice alone rather than guessing. + */ + const commitResolvedState = useCallback( + (resolved: ResolvedBootstrapState): void => { + if (resolved.status === "ready" && resolved.bootstrap.account !== "unavailable") { + appearance.apply(resolved.bootstrap.account.preferences); + } else if (resolved.status === "anonymous") { + appearance.forget(); + } + setState(resolved); + }, + [appearance], + ); + + /** A saved preference, as the account answered, kept in the bootstrap's copy of the account. */ + const recordSavedPreferences = useCallback((preferences: HostAccountPreferences): void => { + setState((current) => + current.status === "ready" && current.bootstrap.account !== "unavailable" + ? { + ...current, + bootstrap: { + ...current.bootstrap, + account: { ...current.bootstrap.account, preferences }, + }, + } + : current, + ); }, []); // A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no @@ -397,9 +445,12 @@ export function HostApp(props: HostAppProps): ReactElement { // out while a verification or reset screen was the last anonymous view would show that screen // again, describing an emailed link the person has long since finished with. setAuthView({ kind: "sign-in" }); - setState( - principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" }, - ); + if (principal.kind === "anonymous") { + appearance.forget(); + setState({ status: "anonymous" }); + } else { + setState({ status: "sign-out-failed" }); + } } catch { if (isCurrent(controller, generation)) { console.error("[ekklesia] sign out failed"); @@ -410,7 +461,7 @@ export function HostApp(props: HostAppProps): ReactElement { activeTransitionRef.current = undefined; } } - }, [beginTransition, config, isCurrent]); + }, [appearance, beginTransition, config, isCurrent]); const onAuthorizationResponse = useCallback( (status: 401 | 403): void => { @@ -440,6 +491,7 @@ export function HostApp(props: HostAppProps): ReactElement { transition.controller.abort(); activeTransitionRef.current = undefined; setAuthView({ kind: "sign-in" }); + appearance.forget(); setState({ status: "anonymous" }); } }) @@ -463,7 +515,7 @@ export function HostApp(props: HostAppProps): ReactElement { } }); }, - [beginTransition, config], + [appearance, beginTransition, config], ); const openEntry = @@ -491,7 +543,11 @@ export function HostApp(props: HostAppProps): ReactElement { ? "Overview" : route.kind === "module" ? (openEntry?.name ?? "This page isn’t available") - : "Page not found"; + : route.kind === "account" + ? "Account" + : route.kind === "account-section" + ? "Appearance" + : "Page not found"; const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle; useEffect(() => { @@ -609,6 +665,7 @@ export function HostApp(props: HostAppProps): ReactElement { config={config} history={history} mainRef={mainRef} + account={bootstrap.account} navigation={bootstrap.navigation} onSignOut={() => void signOut()} pageTitle={pageTitle} @@ -619,6 +676,25 @@ export function HostApp(props: HostAppProps): ReactElement { > {settingsPage !== undefined ? ( + ) : route.kind === "account" || route.kind === "account-section" ? ( + bootstrap.account === "unavailable" ? ( + + ) : route.kind === "account" ? ( + void signOut()} + /> + ) : ( + onAuthorizationResponse(403)} + /> + ) ) : route.kind === "overview" ? ( ) : route.kind === "module" ? ( diff --git a/surfaces/shell/src/browser/ShellLayout.tsx b/surfaces/shell/src/browser/ShellLayout.tsx index 3db0fb3..19dc7b4 100644 --- a/surfaces/shell/src/browser/ShellLayout.tsx +++ b/surfaces/shell/src/browser/ShellLayout.tsx @@ -16,6 +16,7 @@ import type { DashboardNavigation, DashboardSettings, EkklesiaHostConfig, + HostAccount, } from "../host-protocol.js"; import { AccountMenu } from "./AccountMenu.js"; import { DevicePanel } from "./DevicePanel.js"; @@ -45,6 +46,7 @@ export interface ShellLayoutProps { readonly history: HostHistory; readonly navigation: DashboardNavigation; readonly settings: DashboardSettings | "unavailable"; + readonly account: HostAccount | "unavailable"; readonly route: DashboardRoute; readonly viewport: HostViewport; readonly sidebarPreference: SidebarPreference; @@ -89,6 +91,7 @@ function Sidebar(props: { readonly history: HostHistory; readonly navigation: DashboardNavigation; readonly settings: DashboardSettings | "unavailable"; + readonly account: HostAccount | "unavailable"; readonly route: DashboardRoute; readonly mode: SidebarMode; readonly onToggle: () => void; @@ -123,7 +126,12 @@ function Sidebar(props: { {presentation.collapsed ? undefined : }
- +
); @@ -143,6 +151,7 @@ function NavigationDrawer(props: { readonly history: HostHistory; readonly navigation: DashboardNavigation; readonly settings: DashboardSettings | "unavailable"; + readonly account: HostAccount | "unavailable"; readonly route: DashboardRoute; readonly onDismiss: () => void; readonly onNavigate: () => void; @@ -226,7 +235,13 @@ function NavigationDrawer(props: {
- +
@@ -325,6 +340,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement { {compact ? undefined : ( {showDrawer ? ( ` — never inline, so a strict + * `script-src` needs no exception — that runs before the body is parsed. It reads this browser's + * cached copy of the person's choice (`appearance-cache.ts`) and puts it on the document root, so + * an explicit Light or Dark is in place before the first frame instead of after the API answers. + * + * It is deliberately tiny and does nothing else. The cache holds two words and nothing about the + * account; the shell replaces it with what the account says on every load. When there is no cache — + * a first visit, a new device, after sign-out, storage blocked — it does nothing, the page follows + * the system, and the account's answer arrives moments later. + * + * Besides the two attributes the token stylesheet reads (ADR 0060), an explicit choice also narrows + * ``, because the stylesheet is installed by the main script after first + * paint: until then the browser paints its own canvas in the scheme that element names. + */ +try { + const choice = parseAppearanceCache(window.localStorage.getItem(APPEARANCE_CACHE_KEY)); + if (choice) { + const root = document.documentElement; + // The token stylesheet's own attribute names (`DESIGN_TOKEN_APPEARANCE_ATTRIBUTE`, + // `DESIGN_TOKEN_MOTION_ATTRIBUTE`), spelled out to keep this script free of the token package; + // the shell's tests hold them equal. + root.setAttribute("data-ekklesia-appearance", choice.appearance); + root.setAttribute("data-ekklesia-motion", choice.motion); + if (choice.appearance !== "system") { + document + .querySelector('meta[name="color-scheme"]') + ?.setAttribute("content", choice.appearance); + } + } +} catch { + // Storage blocked or unavailable: follow the system until the account answers. +} diff --git a/surfaces/shell/src/browser/appearance-cache.ts b/surfaces/shell/src/browser/appearance-cache.ts new file mode 100644 index 0000000..d423dbb --- /dev/null +++ b/surfaces/shell/src/browser/appearance-cache.ts @@ -0,0 +1,114 @@ +import type { EkklesiaAppearance, EkklesiaMotionPreference } from "@ekklesia/design-tokens"; + +/** + * This browser's copy of the signed-in person's appearance and motion (ADR 0062). + * + * **A cache, never the source of truth.** The person's account holds their preferences, and every + * Dashboard load replaces this copy with what the account says. The copy exists for one reason: + * the account is only known once the API has answered, and by then the page has painted. The + * Dashboard's pre-paint script (`appearance-boot.ts`) reads it synchronously, before the body is + * parsed, so someone who chose Dark does not see a light page first. + * + * It holds two words — the appearance and the motion preference — under one namespaced key. No + * name, email, user id, or anything else about the account: it is safe for anyone with access to + * this browser profile to read, and it is cleared on sign-out so the next person starts from the + * system's setting. + * + * This file is imported by the pre-paint script, so it imports only types: the script must stay + * tiny and must not pull the token package into a render-blocking request. + */ + +export interface AppearanceChoice { + readonly appearance: EkklesiaAppearance; + readonly motion: EkklesiaMotionPreference; +} + +/** One namespaced key, beside the sidebar's (`ekklesia.dashboard.sidebar`). */ +export const APPEARANCE_CACHE_KEY = "ekklesia.dashboard.appearance"; + +// Repeated rather than imported from the token package, to keep the pre-paint script small; the +// shell's tests hold both lists equal to `EKKLESIA_APPEARANCES` and `EKKLESIA_MOTION_PREFERENCES`. +const APPEARANCES: readonly string[] = ["system", "light", "dark"]; +const MOTION_PREFERENCES: readonly string[] = ["system", "reduce", "allow"]; + +/** The serialized form: ` `, for example `dark reduce`. */ +export function serializeAppearanceChoice(choice: AppearanceChoice): string { + return `${choice.appearance} ${choice.motion}`; +} + +/** Strict: exactly two known words. Anything else — including an older or tampered value — is no + * cache at all, and the page follows the system until the account answers. */ +export function parseAppearanceCache(value: unknown): AppearanceChoice | undefined { + if (typeof value !== "string") { + return undefined; + } + const parts = value.split(" "); + const [appearance, motion] = parts; + if ( + parts.length !== 2 || + appearance === undefined || + motion === undefined || + !APPEARANCES.includes(appearance) || + !MOTION_PREFERENCES.includes(motion) + ) { + return undefined; + } + return { + appearance: appearance as EkklesiaAppearance, + motion: motion as EkklesiaMotionPreference, + }; +} + +export interface AppearanceCache { + read(): AppearanceChoice | undefined; + write(choice: AppearanceChoice): void; + clear(): void; +} + +/** + * The browser's `localStorage`, handed in by the entry point. Exactly like the sidebar preference, + * every access is guarded: storage can be missing, blocked, or full, and losing the cache only + * costs the pre-paint hint, never the preference itself. + */ +export function createLocalAppearanceCache( + storage: Pick | undefined, +): AppearanceCache { + return { + clear() { + try { + storage?.removeItem(APPEARANCE_CACHE_KEY); + } catch { + // Nothing to undo: an unreadable cache is already no cache. + } + }, + read() { + try { + return parseAppearanceCache(storage?.getItem(APPEARANCE_CACHE_KEY)); + } catch { + return undefined; + } + }, + write(choice) { + const serialized = serializeAppearanceChoice(choice); + try { + storage?.setItem(APPEARANCE_CACHE_KEY, serialized); + } catch { + // The account still holds the preference; only the pre-paint hint is lost. + } + }, + }; +} + +/** Nothing persisted: for tests, and for a host composed without browser storage. */ +export function createMemoryAppearanceCache(initial?: AppearanceChoice): AppearanceCache { + let choice = initial; + return { + clear() { + choice = undefined; + }, + read: () => choice, + write(next) { + choice = next; + }, + }; +} diff --git a/surfaces/shell/src/browser/appearance-preference.ts b/surfaces/shell/src/browser/appearance-preference.ts new file mode 100644 index 0000000..1ec0b70 --- /dev/null +++ b/surfaces/shell/src/browser/appearance-preference.ts @@ -0,0 +1,108 @@ +import { + createEkklesiaThemeController, + type EkklesiaThemeController, + type EkklesiaThemeTarget, +} from "@ekklesia/design-tokens"; +import type { AppearanceCache, AppearanceChoice } from "./appearance-cache.js"; + +/** + * The signed-in person's appearance and motion, as this Dashboard applies them (ADR 0062). + * + * The **account** is the source of truth; this is the shell's one handle for putting a choice into + * effect. It drives the ADR 0060 theme controller — never a second implementation of the theme — + * and keeps this browser's pre-paint cache in step, so whatever is applied now is also what the next + * page load paints first. + */ +export interface AppearancePreference { + /** What is in effect now. */ + current(): AppearanceChoice; + /** Puts a choice into effect at once, with no reload, and remembers it for the next first paint. */ + apply(choice: AppearanceChoice): void; + /** Another tab of this Dashboard applied a choice: take it up, without writing it back. */ + syncFromCache(): void; + /** Sign-out: follow the system again and forget the cached choice, so the next person to use this + * browser does not inherit it. */ + forget(): void; + /** Notifies after the choice in effect changes. */ + subscribe(listener: () => void): () => void; +} + +export const SYSTEM_APPEARANCE: AppearanceChoice = Object.freeze({ + appearance: "system", + motion: "system", +}); + +export interface CreateAppearancePreferenceOptions { + readonly theme: EkklesiaThemeController; + readonly cache: AppearanceCache; + /** ``, narrowed with an explicit choice exactly as the pre-paint script + * narrows it, so the two never disagree. */ + readonly colorSchemeMeta?: Pick; +} + +export function createAppearancePreference( + options: CreateAppearancePreferenceOptions, +): AppearancePreference { + const listeners = new Set<() => void>(); + // One frozen snapshot per distinct choice, so a React external-store read is stable between + // changes. + let snapshot: AppearanceChoice = Object.freeze({ + appearance: options.theme.appearance(), + motion: options.theme.motion(), + }); + const current = (): AppearanceChoice => snapshot; + const put = (choice: AppearanceChoice): void => { + options.theme.setAppearance(choice.appearance); + options.theme.setMotion(choice.motion); + options.colorSchemeMeta?.setAttribute( + "content", + options.theme.appearance() === "system" ? "light dark" : options.theme.appearance(), + ); + const appearance = options.theme.appearance(); + const motion = options.theme.motion(); + if (appearance !== snapshot.appearance || motion !== snapshot.motion) { + snapshot = Object.freeze({ appearance, motion }); + for (const listener of [...listeners]) { + listener(); + } + } + }; + + return { + apply(choice) { + put(choice); + options.cache.write(current()); + }, + current, + forget() { + options.cache.clear(); + put(SYSTEM_APPEARANCE); + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + syncFromCache() { + put(options.cache.read() ?? SYSTEM_APPEARANCE); + }, + }; +} + +/** A self-contained preference over a detached target: for tests, and for a host composed without a + * document. */ +export function createDetachedAppearancePreference( + cache: AppearanceCache, + initial: AppearanceChoice = SYSTEM_APPEARANCE, +): AppearancePreference { + const attributes = new Map(); + return createAppearancePreference({ + cache, + theme: createEkklesiaThemeController({ + appearance: initial.appearance, + motion: initial.motion, + target: { setAttribute: (name, value) => attributes.set(name, value) }, + }), + }); +} diff --git a/surfaces/shell/src/browser/main.tsx b/surfaces/shell/src/browser/main.tsx index b451c9e..393fb8f 100644 --- a/surfaces/shell/src/browser/main.tsx +++ b/surfaces/shell/src/browser/main.tsx @@ -6,6 +6,8 @@ import { EkklesiaUiStyles } from "@ekklesia/ui"; import { createRoot } from "react-dom/client"; import { consumeAuthLanding } from "../auth-protocol.js"; import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js"; +import { APPEARANCE_CACHE_KEY, createLocalAppearanceCache } from "./appearance-cache.js"; +import { createAppearancePreference } from "./appearance-preference.js"; import { HostApp } from "./HostApp.js"; import { createBrowserHostHistory } from "./host-history.js"; import { createBrowserHostViewport } from "./host-viewport.js"; @@ -36,21 +38,25 @@ const setDocumentTitle = (title: string): void => { document.title = title; }; -/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). Merely - * reading `localStorage` throws where site data is blocked; the shell then remembers the sidebar's - * mode for this page only. */ +/** + * This browser's storage, read once. It holds exactly two things: the desktop sidebar's mode + * (ADR 0058) and the pre-paint copy of the person's appearance (ADR 0062), which is a cache of what + * their account holds and never the source of truth. Merely reading `localStorage` throws where site + * data is blocked; the shell then remembers both for this page only. + */ +const browserStorage = (() => { + try { + return window.localStorage; + } catch { + return undefined; + } +})(); + +/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). */ const viewport = createBrowserHostViewport( typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined, ); -const sidebarPreference = createLocalSidebarPreference( - (() => { - try { - return window.localStorage; - } catch { - return undefined; - } - })(), -); +const sidebarPreference = createLocalSidebarPreference(browserStorage); const container = document.getElementById("ekklesia-host"); if (!container) { @@ -69,22 +75,42 @@ if (!container) { installEkklesiaDesignTokens(document); /** - * The Dashboard's appearance and motion (ADR 0060), applied to the document root before React - * renders anything. + * The Dashboard's appearance and motion (ADR 0060, ADR 0062), applied to the document root before + * React renders anything. * - * Both are `system` until a person chooses otherwise: the token stylesheet then follows the - * operating system's light/dark and reduced-motion settings live, by CSS alone, into every module - * root. This is the one place a remembered choice will be supplied: the account preferences work - * reads it synchronously and passes it here, so an explicit Light or Dark is in place before the - * first frame. Nothing is stored yet, and there is no second, temporary way to choose. + * The page's pre-paint script (`appearance-boot.ts`) has already put this browser's cached choice + * on the root, before the body was parsed; the controller starts from that same cache, so the two + * agree and nothing changes here. Without a cache both are `system`, and the token stylesheet + * follows the operating system's light/dark and reduced-motion settings live, by CSS alone, into + * every module root. Once the account answers, the shell applies what it holds (`HostApp`). */ -createEkklesiaThemeController({ - appearance: "system", - ...(typeof window.matchMedia === "function" - ? { matchMedia: (query: string) => window.matchMedia(query) } - : {}), - motion: "system", - target: document.documentElement, +const appearanceCache = createLocalAppearanceCache(browserStorage); +const cachedAppearance = appearanceCache.read(); +const appearance = createAppearancePreference({ + cache: appearanceCache, + ...(() => { + const meta = document.querySelector('meta[name="color-scheme"]'); + return meta ? { colorSchemeMeta: meta } : {}; + })(), + theme: createEkklesiaThemeController({ + appearance: cachedAppearance?.appearance ?? "system", + ...(typeof window.matchMedia === "function" + ? { matchMedia: (query: string) => window.matchMedia(query) } + : {}), + motion: cachedAppearance?.motion ?? "system", + target: document.documentElement, + }), +}); + +/** + * Another tab of this Dashboard changed the choice, or signed out: the browser tells every other + * tab when this origin's storage changes, so they follow along with no reload and no request. The + * account is still the source of truth — each tab reconciles with it on its next load. + */ +window.addEventListener("storage", (event) => { + if (event.key === APPEARANCE_CACHE_KEY || event.key === null) { + appearance.syncFromCache(); + } }); /** @@ -102,6 +128,7 @@ function renderHost(config: EkklesiaHostConfig | undefined) { } return authLanding ? ( ) : ( ` before anything paints, which + * applies this browser's cached appearance (ADR 0062). */ const packageRoot = new URL("../", import.meta.url); export const dashboardHostAssetUrls = Object.freeze({ html: new URL("dist/browser/index.html", packageRoot).href, script: new URL("dist/browser/assets/ekklesia-dashboard/app.js", packageRoot).href, + appearance: new URL("dist/browser/assets/ekklesia-dashboard/appearance.js", packageRoot).href, }); diff --git a/surfaces/shell/src/dashboard-routes.ts b/surfaces/shell/src/dashboard-routes.ts index d9dfa68..e84ca21 100644 --- a/surfaces/shell/src/dashboard-routes.ts +++ b/surfaces/shell/src/dashboard-routes.ts @@ -13,7 +13,8 @@ * /settings/
a platform Settings section: general, branding, displays, system * /settings/discover/ finding, installing, and managing modules: browse, installed, updates * /settings/ a module's settings (and later its own deeper settings pages) - * /account[/…] the account area + * /account your account + * /account/
a personal settings page: appearance * /[/…] a module's page, and later its own sub-pages * ``` * @@ -50,6 +51,15 @@ export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]; /** The sections that are one page each. Discover is a group of views instead. */ export type SettingsPageSection = Exclude; +/** + * The account area's personal settings pages, in navigation order (ADR 0062). Personal, never + * church administration: that is Settings. They live beneath `/account/`, so none can collide with a + * module id and none is reserved. + */ +export const ACCOUNT_SECTIONS = Object.freeze(["appearance"] as const); + +export type AccountSection = (typeof ACCOUNT_SECTIONS)[number]; + /** * Discover's views, in navigation order: Browse (find and install modules), Installed (see and * manage installed modules), and Updates (module and platform updates). Browse is where Discover @@ -67,6 +77,8 @@ export type DashboardRoute = | { readonly kind: "settings-section"; readonly section: SettingsPageSection } | { readonly kind: "discover"; readonly view: DiscoverView } | { readonly kind: "module-settings"; readonly moduleId: string } + | { readonly kind: "account" } + | { readonly kind: "account-section"; readonly section: AccountSection } | { readonly kind: "not-found" }; /** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */ @@ -76,6 +88,7 @@ const platformPages: ReadonlySet = new Set(DASHBOARD_PLATFORM_PAGES); const settingsSections: ReadonlySet = new Set(SETTINGS_SECTIONS); const discoverViews: ReadonlySet = new Set(DISCOVER_VIEWS); +const accountSections: ReadonlySet = new Set(ACCOUNT_SECTIONS); function isSettingsPageSection(value: string): value is SettingsPageSection { return value !== "discover" && settingsSections.has(value); @@ -85,6 +98,10 @@ function isDiscoverView(value: string): value is DiscoverView { return discoverViews.has(value); } +function isAccountSection(value: string): value is AccountSection { + return accountSections.has(value); +} + function decodeSegment(segment: string): string | undefined { try { const decoded = decodeURIComponent(segment); @@ -120,6 +137,9 @@ export function parseDashboardPath(pathname: string): DashboardRoute { if (first === "settings") { return parseSettingsPath(meaningful.slice(1)); } + if (first === "account") { + return parseAccountPath(meaningful.slice(1)); + } if (first === undefined || platformPages.has(first)) { return { kind: "not-found" }; } @@ -161,6 +181,18 @@ function parseSettingsPath(rest: readonly string[]): DashboardRoute { : { kind: "module-settings", moduleId: name }; } +/** Reads what follows `/account`: the account page itself, or one of its personal settings pages. + * Anything else is `not-found` until it exists. */ +function parseAccountPath(rest: readonly string[]): DashboardRoute { + if (rest.length === 0) { + return { kind: "account" }; + } + const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]); + return rest.length === 1 && name !== undefined && isAccountSection(name) + ? { kind: "account-section", section: name } + : { kind: "not-found" }; +} + /** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its * own path segment. */ export function dashboardPath(route: DashboardDestination): string { @@ -177,5 +209,9 @@ export function dashboardPath(route: DashboardDestination): string { return `/settings/discover/${route.view}`; case "module-settings": return `/settings/${encodeURIComponent(route.moduleId)}`; + case "account": + return "/account"; + case "account-section": + return `/account/${route.section}`; } } diff --git a/surfaces/shell/src/host-protocol.ts b/surfaces/shell/src/host-protocol.ts index d99b8a8..829c1e5 100644 --- a/surfaces/shell/src/host-protocol.ts +++ b/surfaces/shell/src/host-protocol.ts @@ -1,5 +1,11 @@ -import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js"; +import { + EKKLESIA_APPEARANCES, + EKKLESIA_MOTION_PREFERENCES, + type EkklesiaAppearance, + type EkklesiaMotionPreference, +} from "@ekklesia/design-tokens"; import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk"; +import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js"; /** * How this shell reaches the Ekklesia API. @@ -39,6 +45,8 @@ const PRINCIPAL_PATH = "/api/v1/principal"; const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation"; const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings"; const MODULE_DISPLAYS_PATH = "/api/v1/module-displays"; +const ACCOUNT_PATH = "/api/v1/account"; +const ACCOUNT_PREFERENCES_PATH = "/api/v1/account/preferences"; const MODULE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; const MODULE_ID_MAX_LENGTH = 64; @@ -404,6 +412,7 @@ async function requestPlatformMutation( rootRelativePath: string, body: unknown, signal?: AbortSignal, + method: "PATCH" | "POST" = "POST", ): Promise { const performRequest = config.fetch ?? fetch; let response: Response; @@ -413,7 +422,7 @@ async function requestPlatformMutation( cache: "no-store", credentials: "include", headers: { accept: "application/json", "content-type": "application/json" }, - method: "POST", + method, redirect: "error", ...(signal ? { signal } : {}), }); @@ -935,6 +944,79 @@ export async function loadDashboardSettings( ); } +/** + * A signed-in person's own appearance and motion preferences, as their account stores them + * (ADR 0062). The value sets are the design tokens' own, so what the account stores is exactly what + * the theme controller applies. + */ +export interface HostAccountPreferences { + readonly appearance: EkklesiaAppearance; + readonly motion: EkklesiaMotionPreference; +} + +/** Who is signed in, as the account area shows them: their name and email, and their preferences. + * Nothing else about the account reaches the browser. */ +export interface HostAccount { + readonly name: string; + readonly email: string; + readonly preferences: HostAccountPreferences; +} + +/** Strict: exactly the two preferences, each one of its known values. */ +export function parseHostAccountPreferences(value: unknown): HostAccountPreferences { + if ( + !hasExactKeys(value, ["appearance", "motion"]) || + !EKKLESIA_APPEARANCES.includes(value.appearance as EkklesiaAppearance) || + !EKKLESIA_MOTION_PREFERENCES.includes(value.motion as EkklesiaMotionPreference) + ) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ + appearance: value.appearance as EkklesiaAppearance, + motion: value.motion as EkklesiaMotionPreference, + }); +} + +/** Strict: exactly name, email, and preferences. A name or email is rendered as text, never as + * markup or a URL, so the only requirement is that each is a string with something in it. */ +export function parseHostAccount(value: unknown): HostAccount { + if ( + !hasExactKeys(value, ["email", "name", "preferences"]) || + typeof value.name !== "string" || + typeof value.email !== "string" || + value.email.trim().length === 0 + ) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ + email: value.email, + name: value.name, + preferences: parseHostAccountPreferences(value.preferences), + }); +} + +/** The signed-in person's own account. There is no way to ask for anyone else's. */ +export async function loadHostAccount( + config: EkklesiaHostConfig, + signal?: AbortSignal, +): Promise { + return parseHostAccount( + await requestPlatformJson(config, ACCOUNT_PATH, signal, { forbidden: "HOST_ACCESS_DENIED" }), + ); +} + +/** Changes some of the signed-in person's own preferences and returns everything now in effect — + * the server's answer, which the shell applies, rather than what it asked for. */ +export async function saveHostAccountPreferences( + config: EkklesiaHostConfig, + update: Partial, + signal?: AbortSignal, +): Promise { + return parseHostAccountPreferences( + await requestPlatformMutation(config, ACCOUNT_PREFERENCES_PATH, update, signal, "PATCH"), + ); +} + export interface HostBootstrap { readonly principal: ModuleUiPrincipal; readonly navigation: DashboardNavigation; @@ -944,6 +1026,13 @@ export interface HostBootstrap { * is withheld and its pages say they could not be loaded. */ readonly settings: DashboardSettings | "unavailable"; + /** + * The signed-in person's own account, or `"unavailable"` when it could not be loaded (and for an + * anonymous viewer, who has none). Like Settings, an account that cannot be loaded never stops the + * Dashboard: the account control shows no name, the theme stays as this browser last knew it, and + * the account pages say they could not be loaded. + */ + readonly account: HostAccount | "unavailable"; } const EMPTY_SETTINGS: DashboardSettings = Object.freeze({ @@ -968,9 +1057,14 @@ export async function bootstrapHost( ): Promise { const principal = await loadHostPrincipal(config, signal); if (principal.kind === "anonymous") { - return { navigation: EMPTY_NAVIGATION, principal, settings: EMPTY_SETTINGS }; + return { + account: "unavailable", + navigation: EMPTY_NAVIGATION, + principal, + settings: EMPTY_SETTINGS, + }; } - const [navigation, settings] = await Promise.all([ + const [navigation, settings, account] = await Promise.all([ loadDashboardNavigation(config, signal), loadDashboardSettings(config, signal).catch((error: unknown) => { if (signal?.aborted) { @@ -979,6 +1073,13 @@ export async function bootstrapHost( console.error("[ekklesia] settings navigation unavailable", error); return "unavailable" as const; }), + loadHostAccount(config, signal).catch((error: unknown) => { + if (signal?.aborted) { + throw error; + } + console.error("[ekklesia] account unavailable", error); + return "unavailable" as const; + }), ]); - return { navigation, principal, settings }; + return { account, navigation, principal, settings }; } diff --git a/surfaces/shell/src/index.ts b/surfaces/shell/src/index.ts index 9bc62ef..57e6582 100644 --- a/surfaces/shell/src/index.ts +++ b/surfaces/shell/src/index.ts @@ -19,6 +19,8 @@ export { type DashboardNavigationGroup, type DisplayHostBootstrap, type EkklesiaHostConfig, + type HostAccount, + type HostAccountPreferences, type HostBootstrap, type HostEventSourceFactory, type HostEventSourceOptions, @@ -28,16 +30,20 @@ export { type HostProtocolErrorCode, loadDashboardNavigation, loadDashboardSettings, + loadHostAccount, loadHostPrincipal, loadModuleDisplay, moduleDisplayDescriptorPath, parseDashboardNavigation, parseDashboardSettings, parseDisplayRoute, + parseHostAccount, + parseHostAccountPreferences, parseHostModuleDisplay, parseHostPrincipal, resolveApiOrigin, resolveApiUrl, + saveHostAccountPreferences, } from "./host-protocol.js"; export { type CreateModuleUiContextOptions, diff --git a/surfaces/shell/tests/account-appearance.test.tsx b/surfaces/shell/tests/account-appearance.test.tsx new file mode 100644 index 0000000..23a4739 --- /dev/null +++ b/surfaces/shell/tests/account-appearance.test.tsx @@ -0,0 +1,530 @@ +import { + createEkklesiaThemeController, + DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, + DESIGN_TOKEN_MOTION_ATTRIBUTE, +} from "@ekklesia/design-tokens"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + type AppearanceChoice, + createMemoryAppearanceCache, +} from "../src/browser/appearance-cache.js"; +import { createAppearancePreference } from "../src/browser/appearance-preference.js"; +import { HostApp } from "../src/browser/HostApp.js"; +import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js"; +import { createMemoryHostViewport } from "../src/browser/host-viewport.js"; +import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js"; + +declare global { + // eslint-disable-next-line no-var + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +/** + * The account area (ADR 0062): the account control's real identity, `/account`, + * `/account/appearance`, and how a chosen appearance and motion take effect, persist, and are + * reconciled with the account. + */ + +const apiBaseUrl = "https://ekklesia.example.test"; + +const ADA = { + email: "ada@example.test", + name: "Ada Lovelace", + preferences: { appearance: "dark", motion: "reduce" }, +} as const; + +let container: HTMLDivElement; +let root: Root; +let consoleError: ReturnType; + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + consoleError.mockRestore(); +}); + +interface Deferred { + readonly resolve: (response: Response) => void; +} + +interface RenderOptions { + readonly account?: + | typeof ADA + | { readonly email: string; readonly name: string; readonly preferences: AppearanceChoice } + | "fails"; + readonly cached?: AppearanceChoice; + readonly compact?: boolean; + readonly history?: MemoryHostHistory; + /** Answers each preferences PATCH; by default the server merges the change, as the API does. */ + readonly save?: (body: Record) => Response | "hold"; + readonly principalAfterSave?: "authenticated" | "anonymous"; +} + +async function render(path: string, options: RenderOptions = {}) { + const history = options.history ?? createMemoryHostHistory(path); + const calls: { method: string; path: string; body?: unknown }[] = []; + const held: Deferred[] = []; + const titles: string[] = []; + const rootAttributes = new Map(); + const meta = new Map(); + const cache = createMemoryAppearanceCache(options.cached); + const appearance = createAppearancePreference({ + cache, + colorSchemeMeta: { setAttribute: (name, value) => meta.set(name, value) }, + theme: createEkklesiaThemeController({ + appearance: options.cached?.appearance ?? "system", + motion: options.cached?.motion ?? "system", + target: { setAttribute: (name, value) => rootAttributes.set(name, value) }, + }), + }); + let stored = + options.account === "fails" ? undefined : { ...(options.account ?? ADA).preferences }; + let signedIn = true; + const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + const method = init?.method ?? "GET"; + const body = + typeof init?.body === "string" + ? (JSON.parse(init.body) as Record) + : undefined; + calls.push({ body, method, path: url.pathname }); + if (url.pathname === "/api/v1/principal") { + return Response.json( + signedIn ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" }, + ); + } + if (url.pathname === "/api/auth/sign-out") { + signedIn = false; + return Response.json({ success: true }); + } + if (url.pathname === "/api/v1/dashboard/navigation") { + return Response.json({ groups: [], ungrouped: [] }); + } + if (url.pathname === "/api/v1/dashboard/settings") { + return Response.json({ modules: [], sections: [] }); + } + if (url.pathname === "/api/v1/account" && method === "GET") { + return options.account === "fails" + ? Response.json({ code: "ACCOUNT_READ_FAILED" }, { status: 503 }) + : Response.json({ ...(options.account ?? ADA), preferences: stored }); + } + if (url.pathname === "/api/v1/account/preferences" && method === "PATCH" && body) { + const answer = options.save?.(body); + if (answer === "hold") { + return new Promise((resolve) => held.push({ resolve })); + } + if (answer) { + if (answer.status === 403 && options.principalAfterSave === "anonymous") { + signedIn = false; + } + return answer; + } + stored = { + ...(stored ?? { appearance: "system", motion: "system" }), + ...body, + } as AppearanceChoice; + return Response.json(stored); + } + return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); + }) as unknown as typeof fetch; + await act(async () => { + root.render( + titles.push(title)} + sidebarPreference={createMemorySidebarPreference()} + viewport={createMemoryHostViewport(options.compact ?? false)} + />, + ); + }); + return { + appearance, + cache, + calls, + held, + history, + meta, + patches: () => calls.filter((call) => call.method === "PATCH"), + rootAttribute: (name: string) => rootAttributes.get(name), + titles, + }; +} + +function query(testId: string): HTMLElement | null { + return container.querySelector(`[data-testid="${testId}"]`); +} + +function required(testId: string): HTMLElement { + const element = query(testId); + if (!element) { + throw new Error(`missing ${testId}`); + } + return element; +} + +async function click(element: Element): Promise { + await act(async () => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true })); + }); +} + +function radio(group: "appearance" | "motion", value: string): HTMLInputElement { + const input = required(`appearance-${group}-${value}`).querySelector("input"); + if (!(input instanceof HTMLInputElement)) { + throw new Error(`missing ${group} ${value} radio`); + } + return input; +} + +function checked(group: "appearance" | "motion"): string | undefined { + return ( + required(`appearance-${group}`).querySelector("input:checked")?.value ?? + undefined + ); +} + +async function choose(group: "appearance" | "motion", value: string): Promise { + await click(radio(group, value)); +} + +describe("the account control", () => { + it("names who is signed in: their name and email beside their initials", async () => { + await render("/"); + const trigger = required("host-account-menu"); + + expect(trigger.textContent).toContain("Ada Lovelace"); + expect(trigger.textContent).toContain("ada@example.test"); + expect(trigger.querySelector('[data-testid="account-avatar"]')?.textContent).toBe("AL"); + // Initials are decoration; the name is what assistive technology hears. + expect( + trigger.querySelector('[data-testid="account-avatar"]')?.getAttribute("aria-hidden"), + ).toBe("true"); + // No image is ever loaded for an account, and the opaque user id is never shown. + expect(container.querySelector("img")).toBeNull(); + expect(container.textContent).not.toContain("user-1"); + }); + + it("repeats who is signed in inside the menu, above its three destinations", async () => { + await render("/"); + + await click(required("host-account-menu")); + + expect(required("host-account-identity").textContent).toBe("Ada Lovelaceada@example.test"); + expect( + [...required("host-account-menu-list").querySelectorAll('[role="menuitem"]')].map( + (item) => item.textContent, + ), + ).toEqual(["Account", "Appearance", "Sign out"]); + }); + + it("keeps the name in the accessible name of the collapsed control", async () => { + await render("/"); + await click(container.querySelector('[aria-label="Collapse navigation"]') as Element); + + const trigger = required("host-account-menu"); + expect(trigger.querySelector(".ek-shell-visually-hidden")?.textContent).toBe("Ada Lovelace"); + expect(trigger.querySelector('[data-testid="account-avatar"]')).not.toBeNull(); + }); + + it("opens Account and Appearance in place, closing the menu", async () => { + const { history } = await render("/"); + + await click(required("host-account-menu")); + await click(required("host-appearance-link")); + + expect(history.entries).toEqual(["/", "/account/appearance"]); + expect(query("account-appearance")).not.toBeNull(); + expect(query("host-account-menu-list")).toBeNull(); + + await click(required("host-account-menu")); + await click(required("host-account-link")); + expect(history.pathname()).toBe("/account"); + expect(query("account-home")).not.toBeNull(); + }); + + it("names the area instead of the person when the account cannot be loaded", async () => { + await render("/", { account: "fails" }); + + expect(required("host-account-menu").textContent).toBe("Your account"); + expect(query("account-avatar")).toBeNull(); + await click(required("host-account-menu")); + expect(query("host-account-identity")).toBeNull(); + // Every destination still works. + expect(query("host-appearance-link")).not.toBeNull(); + }); +}); + +describe("/account", () => { + it("shows who you are signed in as and links to your own settings — and no administration", async () => { + const { titles } = await render("/account"); + + const page = required("account-home"); + expect(page.querySelector("h1")?.textContent).toBe("Account"); + expect(required("account-name").textContent).toBe("Ada Lovelace"); + expect(required("account-email").textContent).toBe("ada@example.test"); + expect(required("account-home-appearance").getAttribute("href")).toBe("/account/appearance"); + expect(titles.at(-1)).toBe("Account"); + // Nothing editable pretends to exist: no profile form. + expect(page.querySelector("input, textarea, select")).toBeNull(); + // Personal, not Settings: the Settings hierarchy does not open, and no nav item is current. + expect(container.querySelector('nav [aria-current="page"]')).toBeNull(); + }); + + it("shows only the email when the name is blank", async () => { + await render("/account", { + account: { + email: "blank@example.test", + name: " ", + preferences: { appearance: "system", motion: "system" }, + }, + }); + + expect(query("account-name")).toBeNull(); + expect(required("account-email").textContent).toBe("blank@example.test"); + expect(required("host-account-menu").textContent).toContain("blank@example.test"); + }); + + it("signs out, and the signed-out page follows the system with no cached choice", async () => { + const { appearance, cache, calls } = await render("/account"); + expect(appearance.current()).toEqual(ADA.preferences); + + await click(required("account-sign-out")); + + expect(query("host-login")).not.toBeNull(); + expect(calls.some((call) => call.path === "/api/auth/sign-out")).toBe(true); + expect(appearance.current()).toEqual({ appearance: "system", motion: "system" }); + expect(cache.read()).toBeUndefined(); + }); + + it("says when the account could not be loaded, with Retry, and the Dashboard still works", async () => { + await render("/account", { account: "fails" }); + + expect(required("account-load-failed").textContent).toContain( + "Your account could not be loaded", + ); + expect(query("host-sidebar")).not.toBeNull(); + }); + + it("opens the same page after a refresh, and Back and Forward move between the account pages", async () => { + const history = createMemoryHostHistory("/account"); + await render("/account", { history }); + await click(required("account-home-appearance")); + expect(query("account-appearance")).not.toBeNull(); + + await act(async () => history.go(-1)); + expect(query("account-home")).not.toBeNull(); + await act(async () => history.go(1)); + expect(query("account-appearance")).not.toBeNull(); + + await act(async () => { + root.unmount(); + }); + root = createRoot(container); + await render("/account/appearance"); + expect(query("account-appearance")).not.toBeNull(); + }); + + it("is not found beneath it for a page that does not exist", async () => { + await render("/account/profile"); + + expect(query("host-not-found")).not.toBeNull(); + }); +}); + +describe("/account/appearance", () => { + it("shows the account's choices as two native radio groups, with Back to Account", async () => { + const { titles } = await render("/account/appearance"); + const page = required("account-appearance"); + + expect(page.querySelector("h1")?.textContent).toBe("Appearance"); + expect(titles.at(-1)).toBe("Appearance"); + const back = page.querySelector(".ek-page-header__back"); + expect(back?.getAttribute("href")).toBe("/account"); + expect(back?.textContent).toContain("Back to Account"); + + const groups = [...page.querySelectorAll("fieldset")]; + expect(groups.map((group) => group.querySelector("legend")?.textContent)).toEqual([ + "Appearance", + "Reduced motion", + ]); + for (const group of groups) { + // A real radio group: one name, each option labelled, and described by its group's line. + const inputs = [...group.querySelectorAll('input[type="radio"]')]; + expect(inputs).toHaveLength(3); + expect(new Set(inputs.map((input) => input.name)).size).toBe(1); + for (const input of inputs) { + expect(input.closest("label")?.textContent?.length).toBeGreaterThan(0); + } + expect(group.getAttribute("aria-describedby")).toBeTruthy(); + } + expect( + [...page.querySelectorAll('input[name="appearance"]')].map( + (input) => (input as HTMLInputElement).value, + ), + ).toEqual(["system", "light", "dark"]); + expect( + [...page.querySelectorAll('input[name="motion"]')].map( + (input) => (input as HTMLInputElement).value, + ), + ).toEqual(["system", "reduce", "allow"]); + expect(checked("appearance")).toBe("dark"); + expect(checked("motion")).toBe("reduce"); + }); + + it("gives every option a full-width touch target and marks the choice by more than colour", async () => { + await render("/account/appearance", { compact: true }); + + for (const option of container.querySelectorAll(".ek-shell-choice")) { + expect(option.style.minHeight).toBe("var(--ekklesia-control-height-lg)"); + } + expect(required("appearance-appearance-dark").dataset.checked).toBe("true"); + expect(required("appearance-appearance-light").dataset.checked).toBeUndefined(); + }); + + it.each([ + ["appearance", "light"], + ["appearance", "system"], + ["motion", "allow"], + ["motion", "system"], + ] as const)("applies %s %s at once and saves it to the account", async (group, value) => { + const { cache, patches, rootAttribute, history } = await render("/account/appearance"); + + await choose(group, value); + + const attribute = + group === "appearance" ? DESIGN_TOKEN_APPEARANCE_ATTRIBUTE : DESIGN_TOKEN_MOTION_ATTRIBUTE; + expect(rootAttribute(attribute)).toBe(value); + expect(patches()).toEqual([ + { body: { [group]: value }, method: "PATCH", path: "/api/v1/account/preferences" }, + ]); + expect(checked(group)).toBe(value); + expect(cache.read()?.[group]).toBe(value); + // No reload, no navigation: the same page, the same history. + expect(history.entries).toEqual(["/account/appearance"]); + expect(required("appearance-status").textContent).toBe("Saved to your account."); + }); + + it("switches before the server has answered, so the change is immediate", async () => { + const { held, rootAttribute } = await render("/account/appearance", { save: () => "hold" }); + + await choose("appearance", "light"); + + expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light"); + expect(held).toHaveLength(1); + await act(async () => { + held[0]?.resolve(Response.json({ appearance: "light", motion: "reduce" })); + }); + expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light"); + }); + + it("applies what the server answered, which is the source of truth", async () => { + const { rootAttribute } = await render("/account/appearance", { + save: () => Response.json({ appearance: "light", motion: "allow" }), + }); + + await choose("appearance", "light"); + + // Motion changed elsewhere in the meantime; the account's answer wins. + expect(rootAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow"); + expect(checked("motion")).toBe("allow"); + }); + + it("puts the choice back and says so when it could not be saved", async () => { + const { cache, rootAttribute } = await render("/account/appearance", { + save: () => Response.json({ code: "ACCOUNT_WRITE_FAILED" }, { status: 503 }), + }); + + await choose("appearance", "light"); + + expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark"); + expect(checked("appearance")).toBe("dark"); + expect(cache.read()).toEqual(ADA.preferences); + const notice = required("appearance-status").querySelector('[role="alert"]'); + expect(notice?.textContent).toContain("Your choice wasn't saved"); + }); + + it("checks the session when a save is refused, and signs the page out if it has ended", async () => { + const { appearance } = await render("/account/appearance", { + principalAfterSave: "anonymous", + save: () => Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }), + }); + + await choose("motion", "allow"); + + expect(query("host-login")).not.toBeNull(); + expect(appearance.current()).toEqual({ appearance: "system", motion: "system" }); + }); + + it("follows a change made in another tab, without saving it again", async () => { + const { appearance, cache, patches } = await render("/account/appearance"); + + await act(async () => { + cache.write({ appearance: "light", motion: "allow" }); + appearance.syncFromCache(); + }); + + expect(checked("appearance")).toBe("light"); + expect(checked("motion")).toBe("allow"); + expect(patches()).toEqual([]); + }); + + it("is reachable on a phone from the drawer's account menu, which closes on choosing", async () => { + await render("/", { compact: true }); + + await click(required("mobile-navigation-trigger")); + await click(required("host-account-menu")); + await click(required("host-appearance-link")); + + expect(query("account-appearance")).not.toBeNull(); + expect(query("navigation-drawer")).toBeNull(); + }); +}); + +describe("reconciling with the account", () => { + it("replaces this browser's cached choice with the account's on every load", async () => { + const { appearance, cache, meta, rootAttribute } = await render("/", { + cached: { appearance: "light", motion: "allow" }, + }); + + expect(appearance.current()).toEqual(ADA.preferences); + expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark"); + expect(rootAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce"); + expect(meta.get("content")).toBe("dark"); + expect(cache.read()).toEqual(ADA.preferences); + }); + + it("keeps the cached choice when the account cannot be loaded, rather than guessing", async () => { + const { appearance } = await render("/", { + account: "fails", + cached: { appearance: "light", motion: "allow" }, + }); + + expect(appearance.current()).toEqual({ appearance: "light", motion: "allow" }); + }); + + it("follows the system on the signed-out page and forgets the previous person's choice", async () => { + const { appearance, cache } = await render("/", { + cached: { appearance: "dark", motion: "reduce" }, + }); + await click(required("host-account-menu")); + await click(required("host-logout")); + + expect(query("host-login")).not.toBeNull(); + expect(appearance.current()).toEqual({ appearance: "system", motion: "system" }); + expect(cache.read()).toBeUndefined(); + }); +}); diff --git a/surfaces/shell/tests/appearance-preference.test.ts b/surfaces/shell/tests/appearance-preference.test.ts new file mode 100644 index 0000000..b3abf60 --- /dev/null +++ b/surfaces/shell/tests/appearance-preference.test.ts @@ -0,0 +1,301 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createEkklesiaThemeController, + DESIGN_TOKEN_APPEARANCE_ATTRIBUTE, + DESIGN_TOKEN_MOTION_ATTRIBUTE, + EKKLESIA_APPEARANCES, + EKKLESIA_MOTION_PREFERENCES, +} from "@ekklesia/design-tokens"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { accountDisplayName, accountInitials } from "../src/browser/AccountAvatar.js"; +import { + APPEARANCE_CACHE_KEY, + createLocalAppearanceCache, + createMemoryAppearanceCache, + parseAppearanceCache, + serializeAppearanceChoice, +} from "../src/browser/appearance-cache.js"; +import { createAppearancePreference } from "../src/browser/appearance-preference.js"; + +/** + * The browser half of account appearance (ADR 0062): the pre-paint cache, the pre-paint script, + * and the one handle that puts a choice into effect through the ADR 0060 theme controller. + */ + +const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +function memoryStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem: vi.fn((key: string) => values.get(key) ?? null), + removeItem: vi.fn((key: string) => { + values.delete(key); + }), + setItem: vi.fn((key: string, value: string) => { + values.set(key, value); + }), + values, + }; +} + +function throwingStorage() { + const fail = () => { + throw new Error("SecurityError: storage is disabled"); + }; + return { getItem: vi.fn(fail), removeItem: vi.fn(fail), setItem: vi.fn(fail) }; +} + +function recordingTarget() { + const attributes = new Map(); + return { + attributes, + setAttribute: (name: string, value: string) => { + attributes.set(name, value); + }, + }; +} + +describe("the appearance cache", () => { + it("accepts every combination of the design tokens' own values, and round-trips each", () => { + for (const appearance of EKKLESIA_APPEARANCES) { + for (const motion of EKKLESIA_MOTION_PREFERENCES) { + const serialized = serializeAppearanceChoice({ appearance, motion }); + expect(parseAppearanceCache(serialized)).toEqual({ appearance, motion }); + } + } + }); + + it("refuses anything else, so a stale or tampered value is no cache at all", () => { + for (const value of [ + null, + undefined, + "", + "dark", + "dark reduce extra", + "Dark reduce", + "dark reduce", + "sepia reduce", + "dark none", + '{"appearance":"dark","motion":"reduce"}', + 42, + ]) { + expect(parseAppearanceCache(value), String(value)).toBeUndefined(); + } + }); + + it("holds two words under one namespaced key, and nothing about the account", () => { + const storage = memoryStorage(); + const cache = createLocalAppearanceCache(storage); + + cache.write({ appearance: "dark", motion: "reduce" }); + + expect([...storage.values.entries()]).toEqual([[APPEARANCE_CACHE_KEY, "dark reduce"]]); + expect(APPEARANCE_CACHE_KEY).toBe("ekklesia.dashboard.appearance"); + expect(cache.read()).toEqual({ appearance: "dark", motion: "reduce" }); + cache.clear(); + expect(storage.values.size).toBe(0); + expect(cache.read()).toBeUndefined(); + }); + + it("never breaks when storage is missing or throws", () => { + for (const storage of [undefined, throwingStorage()]) { + const cache = createLocalAppearanceCache(storage); + expect(() => cache.write({ appearance: "dark", motion: "allow" })).not.toThrow(); + expect(cache.read()).toBeUndefined(); + expect(() => cache.clear()).not.toThrow(); + } + }); +}); + +describe("the appearance preference", () => { + function build(initial?: { + appearance: "system" | "light" | "dark"; + motion: "system" | "reduce" | "allow"; + }) { + const root = recordingTarget(); + const meta = recordingTarget(); + const cache = createMemoryAppearanceCache(initial); + const theme = createEkklesiaThemeController({ + appearance: initial?.appearance ?? "system", + motion: initial?.motion ?? "system", + target: root, + }); + const preference = createAppearancePreference({ cache, colorSchemeMeta: meta, theme }); + return { cache, meta, preference, root, theme }; + } + + it("drives the ADR 0060 theme controller: the root's two attributes are the whole effect", () => { + const { preference, root, theme } = build(); + + preference.apply({ appearance: "dark", motion: "reduce" }); + + expect(root.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark"); + expect(root.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce"); + expect(theme.colorScheme()).toBe("dark"); + expect(theme.reducedMotion()).toBe(true); + }); + + it("remembers what it applied for the next first paint, and narrows the colour scheme with it", () => { + const { cache, meta, preference } = build(); + + preference.apply({ appearance: "light", motion: "allow" }); + expect(cache.read()).toEqual({ appearance: "light", motion: "allow" }); + expect(meta.attributes.get("content")).toBe("light"); + + preference.apply({ appearance: "system", motion: "allow" }); + expect(meta.attributes.get("content")).toBe("light dark"); + }); + + it("gives a stable snapshot between changes, and notifies only on a real change", () => { + const { preference } = build(); + const listener = vi.fn(); + preference.subscribe(listener); + const before = preference.current(); + + expect(preference.current()).toBe(before); + preference.apply({ appearance: "system", motion: "system" }); + expect(listener).not.toHaveBeenCalled(); + expect(preference.current()).toBe(before); + + preference.apply({ appearance: "dark", motion: "system" }); + expect(listener).toHaveBeenCalledOnce(); + expect(preference.current()).toEqual({ appearance: "dark", motion: "system" }); + }); + + it("follows another tab through the cache, without writing it back", () => { + const { cache, preference } = build({ appearance: "light", motion: "system" }); + const write = vi.spyOn(cache, "write"); + const listener = vi.fn(); + preference.subscribe(listener); + + cache.write({ appearance: "dark", motion: "reduce" }); + write.mockClear(); + preference.syncFromCache(); + + expect(preference.current()).toEqual({ appearance: "dark", motion: "reduce" }); + expect(listener).toHaveBeenCalledOnce(); + expect(write).not.toHaveBeenCalled(); + }); + + it("follows another tab's sign-out back to the system", () => { + const { cache, preference } = build({ appearance: "dark", motion: "reduce" }); + + cache.clear(); + preference.syncFromCache(); + + expect(preference.current()).toEqual({ appearance: "system", motion: "system" }); + }); + + it("forgets on sign-out: the system again, and no cached choice for the next person", () => { + const { cache, meta, preference, root } = build({ appearance: "dark", motion: "reduce" }); + + preference.forget(); + + expect(cache.read()).toBeUndefined(); + expect(root.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system"); + expect(root.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("system"); + expect(meta.attributes.get("content")).toBe("light dark"); + }); +}); + +describe("the pre-paint appearance script", () => { + let meta: HTMLMetaElement; + + beforeEach(() => { + vi.resetModules(); + window.localStorage.clear(); + document.documentElement.removeAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE); + document.documentElement.removeAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE); + meta = document.createElement("meta"); + meta.setAttribute("name", "color-scheme"); + meta.setAttribute("content", "light dark"); + document.head.append(meta); + }); + + afterEach(() => { + meta.remove(); + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + + async function runBootScript(): Promise { + await import("../src/browser/appearance-boot.js"); + } + + it("puts a cached explicit choice on the root and narrows the colour scheme, before anything renders", async () => { + window.localStorage.setItem(APPEARANCE_CACHE_KEY, "dark reduce"); + + await runBootScript(); + + expect(document.documentElement.getAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark"); + expect(document.documentElement.getAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce"); + expect(meta.getAttribute("content")).toBe("dark"); + }); + + it("leaves the colour scheme following the system for a cached System choice", async () => { + window.localStorage.setItem(APPEARANCE_CACHE_KEY, "system allow"); + + await runBootScript(); + + expect(document.documentElement.getAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system"); + expect(document.documentElement.getAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow"); + expect(meta.getAttribute("content")).toBe("light dark"); + }); + + it("does nothing without a valid cache", async () => { + window.localStorage.setItem(APPEARANCE_CACHE_KEY, "neon reduce"); + + await runBootScript(); + + expect(document.documentElement.hasAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(false); + expect(meta.getAttribute("content")).toBe("light dark"); + }); + + it("does nothing, and throws nothing, where storage is blocked", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("SecurityError"); + }); + + await expect(runBootScript()).resolves.toBeUndefined(); + expect(document.documentElement.hasAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(false); + }); + + it("names exactly the token stylesheet's attributes, and reads storage without ever writing it", async () => { + const source = await readFile( + path.join(packageDirectory, "src", "browser", "appearance-boot.ts"), + "utf8", + ); + + expect(source).toContain(`"${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}"`); + expect(source).toContain(`"${DESIGN_TOKEN_MOTION_ATTRIBUTE}"`); + expect(source).not.toMatch(/setItem|removeItem|fetch|cookie/u); + // Types only from the token package, so the render-blocking script stays tiny. + const cacheSource = await readFile( + path.join(packageDirectory, "src", "browser", "appearance-cache.ts"), + "utf8", + ); + expect(cacheSource).toMatch(/^import type \{[^}]*\} from "@ekklesia\/design-tokens";$/mu); + }); +}); + +describe("account initials", () => { + it.each([ + [{ email: "ada@example.test", name: "Ada Lovelace" }, "AL"], + [{ email: "ada@example.test", name: "Ada King Lovelace" }, "AL"], + [{ email: "ada@example.test", name: " ada lovelace " }, "AL"], + [{ email: "cher@example.test", name: "Cher" }, "C"], + [{ email: "zoe@example.test", name: "Émile Zola" }, "ÉZ"], + [{ email: "blank@example.test", name: " " }, "B"], + ])("draws %o as %s", (account, initials) => { + expect(accountInitials(account)).toBe(initials); + }); + + it("shows the email as the name when the name is blank", () => { + expect(accountDisplayName({ email: "blank@example.test", name: " " })).toBe( + "blank@example.test", + ); + expect(accountDisplayName({ email: "ada@example.test", name: "Ada" })).toBe("Ada"); + }); +}); diff --git a/surfaces/shell/tests/dashboard-assets.test.ts b/surfaces/shell/tests/dashboard-assets.test.ts index a8fdd0b..0bd794c 100644 --- a/surfaces/shell/tests/dashboard-assets.test.ts +++ b/surfaces/shell/tests/dashboard-assets.test.ts @@ -8,9 +8,13 @@ import { dashboardHostAssetUrls } from "../src/dashboard-assets.js"; const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); describe("Dashboard host asset locator", () => { - it("exports only frozen immutable href strings for the two exact built files, never the source map", () => { + it("exports only frozen immutable href strings for the three exact built files, never the source map", () => { expect(Object.isFrozen(dashboardHostAssetUrls)).toBe(true); expect(dashboardHostAssetUrls).toEqual({ + appearance: new URL( + "dist/browser/assets/ekklesia-dashboard/appearance.js", + `file://${packageDirectory}/`, + ).href, html: new URL("dist/browser/index.html", `file://${packageDirectory}/`).href, script: new URL( "dist/browser/assets/ekklesia-dashboard/app.js", @@ -43,14 +47,28 @@ describe("Dashboard host asset locator", () => { expect(html).toContain("color: var(--ekklesia-text-primary);"); }); - it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => { + it("loads exactly two fixed, absolute, same-origin scripts and no inline script", async () => { const html = await readFile(path.join(packageDirectory, "index.html"), "utf8"); expect(html).toContain('id="ekklesia-host"'); - expect(html.match(/', ); + expect(html).toContain(''); + // Every script names a source: there is no inline script for a strict script-src to excuse. + expect(html.match(/]*\bsrc=")/gu)).toBeNull(); + }); + + it("applies the cached appearance before first paint: a classic script in , after the colour-scheme element it narrows (ADR 0062)", async () => { + const html = await readFile(path.join(packageDirectory, "index.html"), "utf8"); + const head = html.slice(html.indexOf(""), html.indexOf("")); + const boot = head.indexOf(''); + + // Classic (no `type="module"`, no `defer`, no `async`), so it blocks rendering until it ran. + expect(boot).toBeGreaterThan(-1); + expect(head.indexOf('name="color-scheme"')).toBeLessThan(boot); + expect(head.slice(boot)).not.toMatch(/appearance\.js"[^>]*(?:type=|defer|async)/u); }); it("carries exactly one empty API-origin meta element for the serving API to fill (ADR 0045)", async () => { @@ -76,5 +94,6 @@ describe("Dashboard host asset locator", () => { ); expect(buildScript).toContain('"assets/ekklesia-dashboard/app": path.join('); + expect(buildScript).toContain('"assets/ekklesia-dashboard/appearance": path.join('); }); }); diff --git a/surfaces/shell/tests/dashboard-routes.test.ts b/surfaces/shell/tests/dashboard-routes.test.ts index f9a9b9c..2b74999 100644 --- a/surfaces/shell/tests/dashboard-routes.test.ts +++ b/surfaces/shell/tests/dashboard-routes.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { + ACCOUNT_SECTIONS, DASHBOARD_PLATFORM_PAGES, dashboardPath, parseDashboardPath, @@ -37,8 +38,35 @@ describe("parseDashboardPath", () => { it("never reads a platform page as a module page", () => { expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" }); - for (const pathname of ["/account", "/account/appearance"]) { - expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" }); + expect(parseDashboardPath("/account")).toEqual({ kind: "account" }); + }); + + it("reads the account area: the account page and its personal settings pages (ADR 0062)", () => { + expect(parseDashboardPath("/account")).toEqual({ kind: "account" }); + expect(parseDashboardPath("/account/")).toEqual({ kind: "account" }); + expect(parseDashboardPath("/account/appearance")).toEqual({ + kind: "account-section", + section: "appearance", + }); + expect(parseDashboardPath("/account/appearance/")).toEqual({ + kind: "account-section", + section: "appearance", + }); + expect(dashboardPath({ kind: "account" })).toBe("/account"); + expect(dashboardPath({ kind: "account-section", section: "appearance" })).toBe( + "/account/appearance", + ); + expect(ACCOUNT_SECTIONS).toEqual(["appearance"]); + for (const pathname of [ + "/account/profile", + "/account/appearance/extra", + "/account/Appearance", + "/account//appearance", + "/accounts", + ]) { + expect(parseDashboardPath(pathname), pathname).toEqual( + pathname === "/accounts" ? { kind: "module", moduleId: "accounts" } : { kind: "not-found" }, + ); } }); diff --git a/surfaces/shell/tests/host-app.test.tsx b/surfaces/shell/tests/host-app.test.tsx index 8d4e1bb..bbdead9 100644 --- a/surfaces/shell/tests/host-app.test.tsx +++ b/surfaces/shell/tests/host-app.test.tsx @@ -484,6 +484,8 @@ describe("Ekklesia shell — browser authentication", () => { "/api/v1/dashboard/navigation", // Which Settings destinations the person may see, asked alongside the navigation (ADR 0061). "/api/v1/dashboard/settings", + // Who is signed in and their own preferences, asked alongside them too (ADR 0062). + "/api/v1/account", // The host-owned device panel loads with the rest of the shell for an administrator. "/api/v1/devices", ]); diff --git a/surfaces/shell/tests/host-protocol.test.ts b/surfaces/shell/tests/host-protocol.test.ts index 6cd691d..be0f96f 100644 --- a/surfaces/shell/tests/host-protocol.test.ts +++ b/surfaces/shell/tests/host-protocol.test.ts @@ -4,11 +4,15 @@ import { type HostModuleUiDescriptor, HostProtocolError, loadDashboardNavigation, + loadHostAccount, loadHostPrincipal, parseDashboardNavigation, parseDashboardSettings, + parseHostAccount, + parseHostAccountPreferences, parseHostPrincipal, resolveApiOrigin, + saveHostAccountPreferences, } from "../src/host-protocol.js"; import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js"; @@ -37,6 +41,12 @@ function stubFetch(routes: Readonly Response>>) { return { calls, implementation: implementation as unknown as typeof fetch }; } +const accountPayload = { + email: "ada@example.test", + name: "Ada Lovelace", + preferences: { appearance: "dark", motion: "reduce" }, +}; + const inventoryPayload = { modules: [ { @@ -304,6 +314,7 @@ describe("host protocol — bootstrap", () => { sections: ["displays"], }), "/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }), + "/api/v1/account": () => jsonResponse(accountPayload), }); const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation }); @@ -312,7 +323,9 @@ describe("host protocol — bootstrap", () => { `${apiBaseUrl}/api/v1/principal`, `${apiBaseUrl}/api/v1/dashboard/navigation`, `${apiBaseUrl}/api/v1/dashboard/settings`, + `${apiBaseUrl}/api/v1/account`, ]); + expect(bootstrap.account).toEqual(accountPayload); expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" }); expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor); expect(bootstrap.settings).toEqual({ @@ -571,3 +584,106 @@ describe("module UI host context", () => { ); }); }); + +describe("host protocol — the signed-in person's account (ADR 0062)", () => { + it("reads the account strictly: exactly name, email, and two known preferences", () => { + expect(parseHostAccount(accountPayload)).toEqual(accountPayload); + for (const payload of [ + null, + {}, + { ...accountPayload, image: "https://tracker.example.test/me.png" }, + { ...accountPayload, userId: "user-1" }, + { ...accountPayload, email: "" }, + { ...accountPayload, email: 1 }, + { ...accountPayload, name: null }, + { ...accountPayload, preferences: { appearance: "sepia", motion: "reduce" } }, + { ...accountPayload, preferences: { appearance: "dark", motion: "none" } }, + { ...accountPayload, preferences: { appearance: "dark" } }, + { ...accountPayload, preferences: { appearance: "dark", motion: "allow", radius: "lg" } }, + ]) { + expect(() => parseHostAccount(payload), JSON.stringify(payload)).toThrowError( + expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }), + ); + } + }); + + it.each([ + ["system", "system"], + ["light", "reduce"], + ["dark", "allow"], + ] as const)("accepts appearance %s with motion %s", (appearance, motion) => { + expect(parseHostAccountPreferences({ appearance, motion })).toEqual({ appearance, motion }); + }); + + it("loads the account from its one platform path, with credentials, naming nobody", async () => { + const { calls, implementation } = stubFetch({ + "/api/v1/account": () => jsonResponse(accountPayload), + }); + + expect(await loadHostAccount({ apiBaseUrl, fetch: implementation })).toEqual(accountPayload); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/account`); + expect(calls[0]?.init?.credentials).toBe("include"); + }); + + it("saves a change with one credentialed PATCH of exactly the change, and returns the server's answer", async () => { + const { calls, implementation } = stubFetch({ + "/api/v1/account/preferences": () => jsonResponse({ appearance: "light", motion: "reduce" }), + }); + + const saved = await saveHostAccountPreferences( + { apiBaseUrl, fetch: implementation }, + { appearance: "light" }, + ); + + expect(saved).toEqual({ appearance: "light", motion: "reduce" }); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/account/preferences`); + expect(calls[0]?.init?.method).toBe("PATCH"); + expect(calls[0]?.init?.credentials).toBe("include"); + expect(calls[0]?.init?.redirect).toBe("error"); + expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ appearance: "light" }); + }); + + it("reports a refused save as signed out, and a failed one as unreachable", async () => { + for (const [status, code] of [ + [403, "HOST_UNAUTHENTICATED"], + [503, "HOST_REQUEST_FAILED"], + [400, "HOST_REQUEST_FAILED"], + ] as const) { + const { implementation } = stubFetch({ + "/api/v1/account/preferences": () => jsonResponse({ code: "X" }, status), + }); + await expect( + saveHostAccountPreferences({ apiBaseUrl, fetch: implementation }, { motion: "allow" }), + ).rejects.toMatchObject({ code }); + } + }); + + it("keeps the Dashboard working when the account cannot be loaded", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const { implementation } = stubFetch({ + "/api/v1/account": () => jsonResponse({ code: "ACCOUNT_READ_FAILED" }, 503), + "/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload), + "/api/v1/dashboard/settings": () => jsonResponse({ modules: [], sections: [] }), + "/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }), + }); + + const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation }); + + expect(bootstrap.account).toBe("unavailable"); + expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor); + consoleError.mockRestore(); + }); + + it("never asks for an account on behalf of an anonymous viewer", async () => { + const { calls, implementation } = stubFetch({ + "/api/v1/principal": () => jsonResponse({ kind: "anonymous" }), + }); + + const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation }); + + expect(bootstrap.account).toBe("unavailable"); + expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]); + }); +}); diff --git a/surfaces/shell/tests/host-routing.test.tsx b/surfaces/shell/tests/host-routing.test.tsx index 42d656f..76bfd99 100644 --- a/surfaces/shell/tests/host-routing.test.tsx +++ b/surfaces/shell/tests/host-routing.test.tsx @@ -196,7 +196,7 @@ describe("the Dashboard opens on the page its URL names", () => { }); it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => { - for (const path of ["/settings/displays/add", "/calendar/events", "/account"]) { + for (const path of ["/settings/displays/add", "/calendar/events", "/account/profile"]) { await act(async () => { root.unmount(); }); diff --git a/surfaces/shell/tests/shell-navigation.test.tsx b/surfaces/shell/tests/shell-navigation.test.tsx index e750c08..37599e0 100644 --- a/surfaces/shell/tests/shell-navigation.test.tsx +++ b/surfaces/shell/tests/shell-navigation.test.tsx @@ -384,12 +384,13 @@ describe("the account area", () => { expect(trigger.getAttribute("aria-haspopup")).toBe("menu"); expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(trigger.textContent).toContain("Your account"); - // Only the opaque user id is known to the shell, and it is never shown. + // This harness serves no account, so the control names the area; the opaque user id is never + // shown either way. With an account it names the person (account-appearance.test.tsx). expect(container.textContent).not.toContain("user-1"); expect(query("host-account-menu-list")).toBeNull(); }); - it("opens on activation with focus on its first item, and offers only actions that work today", async () => { + it("opens on activation with focus on its first item, and offers only destinations that work", async () => { await render(); const trigger = required("host-account-menu"); @@ -400,9 +401,14 @@ describe("the account area", () => { expect(trigger.getAttribute("aria-expanded")).toBe("true"); expect(trigger.getAttribute("aria-controls")).toBe(menu.id); const items = [...menu.querySelectorAll('[role="menuitem"]')]; - expect(items.map((item) => item.textContent)).toEqual(["Sign out"]); + expect(items.map((item) => item.textContent)).toEqual(["Account", "Appearance", "Sign out"]); expect(document.activeElement).toBe(items[0]); - expect(menu.querySelector("a")).toBeNull(); + // Account and Appearance are real links to real pages (ADR 0062); Sign out is an action. + expect([...menu.querySelectorAll("a")].map((link) => link.getAttribute("href"))).toEqual([ + "/account", + "/account/appearance", + ]); + expect(items[2]?.tagName).toBe("BUTTON"); }); it("opens from the keyboard with ArrowDown, and Escape closes it and returns focus", async () => { @@ -411,6 +417,8 @@ describe("the account area", () => { await focus(trigger); await key(trigger, "ArrowDown"); + expect(document.activeElement).toBe(required("host-account-link")); + await key(required("host-account-link"), "ArrowUp"); expect(document.activeElement).toBe(required("host-logout")); await key(required("host-logout"), "Escape"); diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index c53775c..c21b4b9 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "core/tests/authorization-store.integration.ts", "core/tests/authorization-bootstrap.integration.ts", "core/tests/dashboard-store.integration.ts", + "core/tests/account-store.integration.ts", "core/tests/module-lifecycle-store.integration.ts", "core/tests/module-configuration-store.integration.ts", "core/tests/module-storage.integration.ts",