Merge pull request 'Stage 1: account area and account-synced Appearance preferences' (#109) from feat/account-appearance into main
CI / verify (push) Successful in 1m11s
CI / infrastructure-smoke (push) Successful in 34s
CI / package-release (push) Successful in 50s
CI / image-build (push) Successful in 35s
CI / migration-smoke (push) Successful in 2m25s
CI / deploy-smoke (same-origin) (push) Successful in 49s
CI / package-publication (push) Successful in 55s
CI / deploy-smoke (split) (push) Successful in 3m7s

Reviewed-on: #109
Reviewed-by: Joey <[email protected]>
This commit was merged in pull request #109.
This commit is contained in:
2026-10-15 11:50:48 +00:00
88 changed files with 6404 additions and 158 deletions
+13 -4
View File
@@ -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 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 ([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 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 schemes, and corners scale from one `radius.scale` token. Displays stay light.
away once account preferences exist. Displays stay light.
**Settings** has its own place in the sidebar **Settings** has its own place in the sidebar
([ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)): a ([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 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. 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 ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
@@ -1007,7 +1014,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
6 permissions registered 7 permissions registered
○ Module lifecycle skipped ○ Module lifecycle skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Module configuration skipped ○ Module configuration skipped
@@ -1017,7 +1024,7 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
7 migrations · checksums valid 9 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180 core:0002_authorization_integrity · 634294731180
@@ -1025,6 +1032,8 @@ Data definitions
core:0004_module_lifecycle_state · c8b5d8b3d48b core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5 core:0006_device_identity · 1db97b7e3ac5
core:0007_verified_bootstrap_owners · 49013062f3e0
core:0008_user_preferences · fa9d4b0bb9d3
PostgreSQL PostgreSQL
✓ Database TLS verified ✓ Database TLS verified
+1
View File
@@ -28,6 +28,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@ekklesia/design-tokens": "workspace:*",
"@ekklesia/module-ui-host": "workspace:*", "@ekklesia/module-ui-host": "workspace:*",
"@ekklesia/module-ui-sdk": "workspace:*", "@ekklesia/module-ui-sdk": "workspace:*",
"@ekklesia/typescript-config": "workspace:*", "@ekklesia/typescript-config": "workspace:*",
+157
View File
@@ -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<string>;
}
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<unknown> {
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;
}
+20
View File
@@ -8,6 +8,7 @@ import type {
AuthorizationStore, AuthorizationStore,
PermissionRegistry, PermissionRegistry,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import type { AccountStore } from "@ekklesia/core/account";
import type { DashboardStore } from "@ekklesia/core/dashboard"; import type { DashboardStore } from "@ekklesia/core/dashboard";
import type { DeviceStore } from "@ekklesia/core/devices"; import type { DeviceStore } from "@ekklesia/core/devices";
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
@@ -28,6 +29,7 @@ import type { Context, MiddlewareHandler } from "hono";
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; 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 type { DashboardHostAssets } from "./dashboard-host-assets.js";
import { createDashboardHostRoutes } from "./dashboard-host-routes.js"; import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
import { createDashboardRoutes } from "./dashboard-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 * presentation only — nothing reachable through this store influences discovery, dependency
* ordering, module startup, or any authorization decision. */ * ordering, module startup, or any authorization decision. */
readonly dashboard: DashboardStore; 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 * 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 * 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/` // 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 // belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
// rather than inside it. See `./module-lifecycle-routes.js`. // rather than inside it. See `./module-lifecycle-routes.js`.
+24 -5
View File
@@ -3,7 +3,9 @@ import { readFile } from "node:fs/promises";
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets"; 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
* `<head>` (ADR 0062).
* *
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound, * 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 * 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 htmlEtag: string;
readonly script: string; readonly script: string;
readonly scriptEtag: string; readonly scriptEtag: string;
readonly appearanceScript: string;
readonly appearanceScriptEtag: string;
} }
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package /** 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 * 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. * valid page that can never render anything.
*/ */
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets { export function createDashboardHostAssets(
if (html.trim().length === 0 || script.trim().length === 0) { html: string,
script: string,
appearanceScript: string,
): DashboardHostAssets {
if (
html.trim().length === 0 ||
script.trim().length === 0 ||
appearanceScript.trim().length === 0
) {
throw new DashboardHostAssetError(); throw new DashboardHostAssetError();
} }
return Object.freeze({ return Object.freeze({
appearanceScript,
appearanceScriptEtag: strongEtag(appearanceScript),
html, html,
htmlEtag: strongEtag(html), htmlEtag: strongEtag(html),
script, script,
@@ -92,7 +106,7 @@ export function renderDashboardPage(html: string, apiOrigin: string): string {
return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`); return parts.join(`<meta content="${escaped}" name="ekklesia-api-origin" />`);
} }
/** 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 * 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. */ * `/` with a 404 or a page that cannot find its API. */
export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> { export async function loadDashboardHostAssets(apiOrigin: string): Promise<DashboardHostAssets> {
@@ -101,7 +115,12 @@ export async function loadDashboardHostAssets(apiOrigin: string): Promise<Dashbo
// rejected sibling read is left running after this function has already failed. // rejected sibling read is left running after this function has already failed.
const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8"); const html = await readFile(new URL(dashboardHostAssetUrls.html), "utf8");
const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8"); const script = await readFile(new URL(dashboardHostAssetUrls.script), "utf8");
return createDashboardHostAssets(renderDashboardPage(html, apiOrigin), script); const appearanceScript = await readFile(new URL(dashboardHostAssetUrls.appearance), "utf8");
return createDashboardHostAssets(
renderDashboardPage(html, apiOrigin),
script,
appearanceScript,
);
} catch { } catch {
throw new DashboardHostAssetError(); throw new DashboardHostAssetError();
} }
+20 -2
View File
@@ -17,6 +17,8 @@ import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard
*/ */
export const DASHBOARD_HOST_PAGE_PATH = "/"; export const DASHBOARD_HOST_PAGE_PATH = "/";
export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js"; export const DASHBOARD_HOST_SCRIPT_PATH = "/assets/ekklesia-dashboard/app.js";
/** The pre-paint appearance script (ADR 0062), beside the application script. */
export const DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH = "/assets/ekklesia-dashboard/appearance.js";
/** /**
* Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The * Revalidate-always with a strong `ETag`, exactly the module UI and display-host script policy. The
@@ -56,8 +58,12 @@ export class DashboardHostConfigurationError extends Error {
* a composition can never pair bytes with a validator computed from different bytes. */ * a composition can never pair bytes with a validator computed from different bytes. */
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets { function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
try { try {
const snapshot = createDashboardHostAssets(input.html, input.script); const snapshot = createDashboardHostAssets(input.html, input.script, input.appearanceScript);
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) { if (
snapshot.htmlEtag !== input.htmlEtag ||
snapshot.scriptEtag !== input.scriptEtag ||
snapshot.appearanceScriptEtag !== input.appearanceScriptEtag
) {
throw new DashboardHostConfigurationError(); throw new DashboardHostConfigurationError();
} }
return snapshot; return snapshot;
@@ -169,5 +175,17 @@ export function createDashboardHostRoutes(
), ),
); );
// Same-origin, revalidated like the application script, and carrying nothing but code: the
// person's cached choice lives in their browser and is read by this script, never served by it.
routes.get(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, (c) =>
fixedAsset(
c.req.header("if-none-match"),
snapshot.appearanceScript,
snapshot.appearanceScriptEtag,
"text/javascript; charset=utf-8",
{},
),
);
return routes; return routes;
} }
+3
View File
@@ -1,4 +1,5 @@
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts"; import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account";
import { import {
type AuthorizationDrizzleDatabase, type AuthorizationDrizzleDatabase,
createAuthorizationDrizzleView, createAuthorizationDrizzleView,
@@ -21,6 +22,7 @@ import {
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
export interface ApiDatabaseHandle { export interface ApiDatabaseHandle {
readonly account: AccountDrizzleDatabase;
readonly authorization: AuthorizationDrizzleDatabase; readonly authorization: AuthorizationDrizzleDatabase;
readonly devices: DeviceDrizzleDatabase; readonly devices: DeviceDrizzleDatabase;
readonly dashboard: DashboardDrizzleDatabase; readonly dashboard: DashboardDrizzleDatabase;
@@ -74,6 +76,7 @@ export function createApiDatabase(
const { close, pool } = createValidatedPostgresPool(databaseUrl, options); const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
return Object.freeze({ return Object.freeze({
account: createAccountDrizzleView(pool),
authorization: createAuthorizationDrizzleView(pool), authorization: createAuthorizationDrizzleView(pool),
close, close,
devices: createDeviceDrizzleView(pool), devices: createDeviceDrizzleView(pool),
+3
View File
@@ -6,6 +6,7 @@ import {
createAuthorizationEvaluator, createAuthorizationEvaluator,
createAuthorizationStore, createAuthorizationStore,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { createAccountStore } from "@ekklesia/core/account";
import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices"; import { createDeviceStore } from "@ekklesia/core/devices";
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host"; 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. // A typed view over the same single pool as identity, authorization, and the module facade.
dashboard: createDashboardStore(databaseHandle.dashboard), dashboard: createDashboardStore(databaseHandle.dashboard),
// The same pool again: a person's own account summary and preferences (ADR 0062).
account: createAccountStore(databaseHandle.account),
dashboardHostAssets, dashboardHostAssets,
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup // 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. // has completed whenever it can answer; it stops counting as complete once shutdown begins.
+340
View File
@@ -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: "[email protected]", name: "Ada Lovelace", userId: adaId },
{
email: "[email protected]",
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<string, string> = { "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: "[email protected]",
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<string, unknown>;
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<string, string>;
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<string, string>;
};
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");
});
});
+161 -3
View File
@@ -260,6 +260,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", "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("[email protected]");
grace = await person("[email protected]");
});
it("shows each person their own name, email, and the defaults, with no grant", async () => {
expect(await account(ada.cookie)).toEqual({
body: {
email: "[email protected]",
name: "Integration Test User",
preferences: { appearance: "system", motion: "system" },
},
status: 200,
});
expect(((await account(grace.cookie)).body as { email: string }).email).toBe(
"[email protected]",
);
});
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, "[email protected]", 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", () => { describe.sequential("live API — startup and migration guarantees", () => {
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => { it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
const database = await createDisposableDatabase("unmigrated"); 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).toMatchObject({ status: "pass" });
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
expect(permissionsCheck).toMatchObject({ 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.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
// `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one. // `core.modules.configuration.manage`, `core.devices.manage`, `core.account.self.manage`)
details: ["7 permissions registered"], // plus the fixture module's one.
details: ["8 permissions registered"],
status: "pass", status: "pass",
}); });
// Doctor and permission composition read only the manifest — the fixture's register/start // Doctor and permission composition read only the manifest — the fixture's register/start
+24
View File
@@ -30,6 +30,7 @@ import {
createApiApp as createApiAppWithModules, createApiApp as createApiAppWithModules,
} from "../src/app.js"; } from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -111,6 +112,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -130,6 +132,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -366,6 +369,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -391,6 +395,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -418,6 +423,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -447,6 +453,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -476,6 +483,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -502,6 +510,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -530,6 +539,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -555,6 +565,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -582,6 +593,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -616,6 +628,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -651,6 +664,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -691,6 +705,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -713,6 +728,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -737,6 +753,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -760,6 +777,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -781,6 +799,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -809,6 +828,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -840,6 +860,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -874,6 +895,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -899,6 +921,7 @@ describe("Ekklesia API Hono application", () => {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -938,6 +961,7 @@ describe("split Dashboard and API origins (ADR 0045)", () => {
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
moduleRealtime, moduleRealtime,
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
+34 -8
View File
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
dashboardHostAssetUrls: Object.freeze({ dashboardHostAssetUrls: Object.freeze({
html: "file:///surface/browser/index.html", html: "file:///surface/browser/index.html",
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js", 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", () => { describe("Dashboard host asset loading", () => {
beforeEach(() => readFileMock.mockReset()); 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 = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`; const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
const script = "export const dashboardHost = true;\n"; 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); const assets = await loadDashboardHostAssets(API_ORIGIN);
expect(readFileMock.mock.calls).toEqual([ expect(readFileMock.mock.calls).toEqual([
[new URL("file:///surface/browser/index.html"), "utf8"], [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/app.js"), "utf8"],
[new URL("file:///surface/browser/assets/ekklesia-dashboard/appearance.js"), "utf8"],
]); ]);
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`; const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
expect(assets).toEqual({ expect(assets).toEqual({
appearanceScript,
appearanceScriptEtag: sha256Etag(appearanceScript),
html: rendered, html: rendered,
htmlEtag: sha256Etag(rendered), htmlEtag: sha256Etag(rendered),
script, script,
@@ -83,12 +91,27 @@ describe("Dashboard host asset loading", () => {
expect(readFileMock).toHaveBeenCalledTimes(2); expect(readFileMock).toHaveBeenCalledTimes(2);
}); });
it("rejects an empty page or script as an unavailable shell build", () => { it("fails when the appearance script is missing even though the page and script were read", async () => {
for (const [html, script] of [ readFileMock
["", "export {};"], .mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
["<html></html>", " \n"], .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 {};", "(() => {})();"],
["<html></html>", " \n", "(() => {})();"],
["<html></html>", "export {};", " "],
] as const) { ] as const) {
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError); expect(() => createDashboardHostAssets(html, script, appearance)).toThrow(
DashboardHostAssetError,
);
} }
}); });
@@ -99,7 +122,10 @@ describe("Dashboard host asset loading", () => {
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />', '<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
]) { ]) {
readFileMock.mockReset(); readFileMock.mockReset();
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n"); readFileMock
.mockResolvedValueOnce(html)
.mockResolvedValueOnce("export {};\n")
.mockResolvedValueOnce("(() => {})();\n");
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf( await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
DashboardHostAssetError, DashboardHostAssetError,
); );
+64 -5
View File
@@ -38,6 +38,7 @@ import {
} from "../src/dashboard-host-assets.js"; } from "../src/dashboard-host-assets.js";
import { import {
DASHBOARD_HOST_PAGE_PATH, DASHBOARD_HOST_PAGE_PATH,
DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH,
DASHBOARD_HOST_SCRIPT_PATH, DASHBOARD_HOST_SCRIPT_PATH,
DashboardHostConfigurationError, DashboardHostConfigurationError,
createDashboardHostRoutes, createDashboardHostRoutes,
@@ -51,6 +52,7 @@ import {
DISPLAY_HOST_SCRIPT_PATH, DISPLAY_HOST_SCRIPT_PATH,
DISPLAY_SERVICE_WORKER_PATH, DISPLAY_SERVICE_WORKER_PATH,
} from "../src/module-display-routes.js"; } 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { import {
@@ -73,6 +75,8 @@ function sha256Etag(text: string): string {
const PAGE_ETAG = sha256Etag(PAGE_HTML); const PAGE_ETAG = sha256Etag(PAGE_HTML);
const SCRIPT_ETAG = sha256Etag(SCRIPT); const SCRIPT_ETAG = sha256Etag(SCRIPT);
const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n";
const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_SCRIPT);
function fakeRuntime(): EkklesiaIdentityRuntime { function fakeRuntime(): EkklesiaIdentityRuntime {
return { return {
@@ -156,8 +160,10 @@ function buildApp(
}, },
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT), dashboardHostAssets:
options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
health: readyHealth(), health: readyHealth(),
runtime, runtime,
trustedOrigins: [trustedOrigin], trustedOrigins: [trustedOrigin],
@@ -357,7 +363,10 @@ describe("Dashboard deep links (ADR 0057)", () => {
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) { for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
expect( expect(
() => () =>
createDashboardHostRoutes(createDashboardHostAssets(PAGE_HTML, SCRIPT), { modulePages }), createDashboardHostRoutes(
createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
{ modulePages },
),
JSON.stringify(modulePages), JSON.stringify(modulePages),
).toThrow(DashboardHostConfigurationError); ).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", () => { 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(); const { app } = buildApp();
for (const path of [ for (const path of [
@@ -472,6 +518,10 @@ describe("Dashboard host boundaries", () => {
"/assets/ekklesia-dashboard/index.html", "/assets/ekklesia-dashboard/index.html",
"/assets/", "/assets/",
"/assets/ekklesia-dashboard/APP.JS", "/assets/ekklesia-dashboard/APP.JS",
"/assets/ekklesia-dashboard/appearance.js.map",
"/assets/ekklesia-dashboard/appearance.js/",
"/assets/ekklesia-dashboard/APPEARANCE.JS",
"/appearance.js",
"/dashboard", "/dashboard",
"/login", "/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", () => { 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 [ for (const assets of [
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` }, { ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
{ ...consistent, scriptEtag: PAGE_ETAG }, { ...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); expect(() => buildApp({ assets })).toThrow(DashboardHostConfigurationError);
} }
+2
View File
@@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createApiApp } from "../src/app.js"; import { createApiApp } from "../src/app.js";
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(options.configuration ?? {}), dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -28,6 +28,7 @@ import {
DASHBOARD_SETTINGS_PATH, DASHBOARD_SETTINGS_PATH,
type DashboardSettingsResponse, type DashboardSettingsResponse,
} from "../src/dashboard-settings-routes.js"; } 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
@@ -127,6 +128,7 @@ function build(options: Options = {}) {
} as unknown as AuthorizationStore; } as unknown as AuthorizationStore;
const app = createApiApp({ const app = createApiApp({
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
+2
View File
@@ -24,6 +24,7 @@ import {
HEALTH_READY_PATH, HEALTH_READY_PATH,
READINESS_DATABASE_TIMEOUT_MS, READINESS_DATABASE_TIMEOUT_MS,
} from "../src/health-routes.js"; } from "../src/health-routes.js";
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
@@ -91,6 +92,7 @@ function buildApp(
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health, health,
+1 -1
View File
@@ -20,7 +20,7 @@ vi.mock("@hono/node-server", () => ({ serve: serveMock }));
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => { vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>(); const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
loadDashboardHostAssetsMock.mockImplementation(async () => loadDashboardHostAssetsMock.mockImplementation(async () =>
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"), actual.createDashboardHostAssets("<!doctype html>", "export {};\n", "(() => {})();\n"),
); );
return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock }; return { ...actual, loadDashboardHostAssets: loadDashboardHostAssetsMock };
}); });
@@ -34,6 +34,7 @@ import { createApiApp } from "../src/app.js";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js"; import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js"; import { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules), registry: composePlatformPermissionRegistry(modules),
store: authorizationStore, store: authorizationStore,
}, },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -249,6 +249,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -279,6 +280,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
"db-facade:0000_initial", "db-facade:0000_initial",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
@@ -37,6 +37,7 @@ import { moduleApiBaseUrl } from "../src/module-http-routes.js";
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js"; import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules), registry: composePlatformPermissionRegistry(modules),
store: fakeAuthorizationStore(), store: fakeAuthorizationStore(),
}, },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -30,6 +30,7 @@ import {
mergedVaryHeader, mergedVaryHeader,
} from "../src/module-http-routes.js"; } from "../src/module-http-routes.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js"; import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js"; import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
@@ -125,6 +126,7 @@ function buildModuleApp(options: {
moduleLifecycle: emptyModuleLifecycle(), moduleLifecycle: emptyModuleLifecycle(),
moduleConfiguration: emptyModuleConfiguration(), moduleConfiguration: emptyModuleConfiguration(),
moduleStorage: emptyModuleStorage(), moduleStorage: emptyModuleStorage(),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -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 { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js"; import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
registry: composePlatformPermissionRegistry(modules), registry: composePlatformPermissionRegistry(modules),
store: authorizationStore, store: authorizationStore,
}, },
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
+42 -1
View File
@@ -33,8 +33,10 @@ import {
createModuleUiContext, createModuleUiContext,
type EkklesiaHostConfig, type EkklesiaHostConfig,
moduleUiSourceUrl, moduleUiSourceUrl,
saveHostAccountPreferences,
} from "@ekklesia/shell"; } from "@ekklesia/shell";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -216,6 +218,8 @@ interface HarnessOptions {
* single module proves very little. */ * single module proves very little. */
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>; readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
readonly platformOwner?: boolean; readonly platformOwner?: boolean;
/** People with an account, for the account area's routes (ADR 0062). */
readonly people?: Parameters<typeof createInMemoryAccountStore>[0];
} }
interface Harness { interface Harness {
@@ -289,6 +293,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
}, },
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(options.people ?? []),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -302,7 +307,10 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
if (url.pathname.startsWith("/api/v1/modules/")) { if (url.pathname.startsWith("/api/v1/modules/")) {
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`); 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; }) as unknown as typeof fetch;
return { 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: "[email protected]", name: "Viewer Person", userId: viewerId }],
principal: createAuthenticatedPrincipal(viewerId),
});
try {
const first = await bootstrapHost(harness.config);
expect(first.account).toEqual({
email: "[email protected]",
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 () => { it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => {
const harness = await startHarness(); const harness = await startHarness();
try { try {
@@ -24,6 +24,7 @@ import {
} from "@ekklesia/core/modules"; } from "@ekklesia/core/modules";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise<Installation> {
}, },
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
+2
View File
@@ -18,6 +18,7 @@ import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/mod
import { Window } from "happy-dom"; import { Window } from "happy-dom";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js"; import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
import { readyHealth } from "./support/ready-health.js"; import { readyHealth } from "./support/ready-health.js";
@@ -254,6 +255,7 @@ async function install(
}, },
moduleConfiguration: moduleConfigurationOptionFor(modules), moduleConfiguration: moduleConfigurationOptionFor(modules),
moduleStorage: moduleStorageOptionFor(modules), moduleStorage: moduleStorageOptionFor(modules),
account: createInMemoryAccountStore(),
dashboard: createInMemoryDashboardStore(), dashboard: createInMemoryDashboardStore(),
dashboardHostAssets: testDashboardHostAssets(), dashboardHostAssets: testDashboardHostAssets(),
health: readyHealth(), health: readyHealth(),
@@ -9,5 +9,6 @@ export function testDashboardHostAssets(): DashboardHostAssets {
return createDashboardHostAssets( return createDashboardHostAssets(
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>', '<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
"export const dashboardHost = true;\n", "export const dashboardHost = true;\n",
"(() => {})();\n",
); );
} }
@@ -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<AccountPreferences>;
}
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<string, { name: string; email: string; preferences: AccountPreferences }>(
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<AccountSummary | undefined> {
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<AccountPreferences> {
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 };
},
};
}
+1
View File
@@ -9,6 +9,7 @@ export default defineConfig({
"./src/modules/lifecycle-schema.ts", "./src/modules/lifecycle-schema.ts",
"./src/modules/configuration-schema.ts", "./src/modules/configuration-schema.ts",
"./src/devices/schema.ts", "./src/devices/schema.ts",
"./src/account/schema.ts",
], ],
out: "./drizzle", out: "./drizzle",
migrations: { migrations: {
+11
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+6
View File
@@ -49,6 +49,12 @@
"destructive": { "destructive": {
"mode": "none" "mode": "none"
} }
},
{
"tag": "0008_user_preferences",
"destructive": {
"mode": "none"
}
} }
] ]
} }
+7
View File
@@ -57,6 +57,13 @@
"when": 1787543773058, "when": 1787543773058,
"tag": "0007_verified_bootstrap_owners", "tag": "0007_verified_bootstrap_owners",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1792036365485,
"tag": "0008_user_preferences",
"breakpoints": true
} }
] ]
} }
+4
View File
@@ -12,6 +12,10 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./account": {
"types": "./dist/account/index.d.ts",
"import": "./dist/account/index.js"
},
"./modules": { "./modules": {
"types": "./dist/modules/index.d.ts", "types": "./dist/modules/index.d.ts",
"import": "./dist/modules/index.js" "import": "./dist/modules/index.js"
+13
View File
@@ -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<typeof accountSchema>;
/**
* 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 });
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./database.js";
export * from "./preferences.js";
export * from "./schema.js";
export * from "./store.js";
+108
View File
@@ -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<AccountPreferences>;
const PREFERENCE_VALUES: Readonly<Record<keyof AccountPreferences, readonly string[]>> =
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,
});
}
+56
View File
@@ -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;
+156
View File
@@ -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<Record<AccountStoreErrorCode, string>> = 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<AccountSummary | undefined>;
/** Changes the named preferences and keeps the rest, returning everything now in effect. */
updatePreferences(
userId: EkklesiaUserId,
update: AccountPreferencesUpdate,
): Promise<AccountPreferences>;
}
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<AccountSummary | undefined> {
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<AccountPreferences> {
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");
}
},
});
}
+19
View File
@@ -169,6 +169,20 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId
*/ */
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage"); 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 * 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 * 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.", description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE, 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,
}),
]); ]);
/** /**
+178
View File
@@ -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");
});
});
+378
View File
@@ -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<string>();
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<Client> {
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<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function dropDatabase(databaseName: string): Promise<void> {
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<void> {
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<void> {
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<void>;
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", "[email protected]");
await insertPerson(admin, grace, "Grace Hopper", "[email protected]");
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: "[email protected]",
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: "[email protected]",
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();
});
});
+14 -1
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { import {
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
CORE_DEVICES_MANAGE, CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE,
@@ -133,7 +134,13 @@ describe("CORE_PERMISSION_REGISTRY", () => {
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE); 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([ expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_MODULES_UI_READ, CORE_MODULES_UI_READ,
@@ -141,6 +148,7 @@ describe("CORE_PERMISSION_REGISTRY", () => {
CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE,
CORE_DEVICES_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.", description: "Approve display device pairing, list paired devices, and revoke a device.",
id: CORE_DEVICES_MANAGE, 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); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
for (const declaration of CORE_PERMISSION_DECLARATIONS) { for (const declaration of CORE_PERMISSION_DECLARATIONS) {
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -243,6 +244,8 @@ describe.sequential("live Core authorization store", () => {
"ekklesia_core_platform_owner_bootstrap", "ekklesia_core_platform_owner_bootstrap",
"ekklesia_core_session", "ekklesia_core_session",
"ekklesia_core_user", "ekklesia_core_user",
// A person's own preferences, one typed column each (ADR 0062).
"ekklesia_core_user_preference",
"ekklesia_core_verification", "ekklesia_core_verification",
]); ]);
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
+1 -1
View File
@@ -92,7 +92,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
6 permissions registered 7 permissions registered
○ Module lifecycle skipped ○ Module lifecycle skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Module configuration skipped ○ Module configuration skipped
@@ -170,12 +170,14 @@ describe("the browser shell reaches none of it", () => {
it("never puts a reset token in browser storage", async () => { 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 // 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 // the life of one form and nowhere else. The shell's uses of browser storage are pinned
// sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is // exactly: the desktop sidebar's expanded/collapsed mode (ADR 0058) and the pre-paint copy of
// named only by the entry point, and only to hand it to the sidebar preference, whose one write // the person's appearance (ADR 0062). `localStorage` is named only by the entry point, once, to
// is its own fixed key and a validated mode. // 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 files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
const writes: string[] = []; const writes: string[] = [];
const removals: string[] = [];
for (const file of files) { for (const file of files) {
const code = withoutComments(file.text); const code = withoutComments(file.text);
expect(code, file.path).not.toContain("sessionStorage"); 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]}`, (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"))) { if (file.path.endsWith(path.join("browser", "main.tsx"))) {
expect(code.match(/localStorage/gu), file.path).toHaveLength(1); expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
expect(code).toMatch( 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 { } else {
expect(code, file.path).not.toContain("localStorage"); expect(code, file.path).not.toContain("localStorage");
} }
} }
expect(writes).toEqual([ 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`, `${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. // The files that ever hold the token touch no storage API at all.
for (const file of files.filter((candidate) => for (const file of files.filter((candidate) =>
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) => ["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -182,6 +182,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners", "core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
+5 -2
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { import {
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_DASHBOARD_NAVIGATION_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
CORE_DEVICES_MANAGE, CORE_DEVICES_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE,
CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE,
@@ -75,8 +76,9 @@ describe("composePlatformPermissionRegistry", () => {
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true); expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true); expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true); expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
// Core's own five declarations plus one from each module. expect(registry.has(CORE_ACCOUNT_SELF_MANAGE)).toBe(true);
expect(registry.declarations).toHaveLength(8); // 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", () => { 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_LIFECYCLE_MANAGE,
CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE,
CORE_DEVICES_MANAGE, CORE_DEVICES_MANAGE,
CORE_ACCOUNT_SELF_MANAGE,
"calendar.events.read", "calendar.events.read",
"calendar.events.write", "calendar.events.write",
"people.directory.read", "people.directory.read",
@@ -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 `<head>`, after
`<meta name="color-scheme">`. 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.
+1
View File
@@ -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 - [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 - [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/<module-id>`](0061-settings-hierarchy-and-module-settings-routes.md) — Accepted - [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<module-id>`](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
+8 -1
View File
@@ -607,7 +607,14 @@ hierarchy in the same sidebar: a landing page, the platform's General, Branding,
`/settings/<module-id>`. Which of them a person sees is evaluated by the server `/settings/<module-id>`. 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 (`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 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 `<head>` 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 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 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 inherit into every module artifact, so a module reads `var(--ekklesia-…)` with nothing to import at
+61 -13
View File
@@ -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`. | | `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 /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 /` | 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 `<head>`. `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/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 /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). | | `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/<module-id>/<channel>` | 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-realtime/<module-id>/<channel>` | 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/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/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). | | `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/<module-id>/<version>` | 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/<module-id>/<version>` | 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/<module-id>/<version>/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). | | `GET /api/v1/module-ui/<module-id>/<version>/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 unset, the session cookie stays host-only, and no second static server or proxy file root is
needed. In the split topology needed. In the split topology
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards ([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: exactly like the display host:
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the - **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 exact URLs of three built files: `dist/browser/index.html`,
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any `dist/browser/assets/ekklesia-dashboard/app.js`, and the pre-paint
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256 `dist/browser/assets/ekklesia-dashboard/appearance.js`
strong `ETag` for each. ([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 - **The API's origin is written in at startup.** The built page carries one empty
`<meta content="" name="ekklesia-api-origin" />`. Before computing the page's `ETag`, `startApi()` `<meta content="" name="ekklesia-api-origin" />`. 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 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 `DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
already opened. It never carries a path or the filesystem error. Build the shell first; already opened. It never carries a path or the filesystem error. Build the shell first;
`pnpm api:start` already runs the full build. `pnpm api:start` already runs the full build.
- **Two exact routes, never a directory.** `GET /` answers the page and - **Exact routes, never a directory.** `GET /` (and the Dashboard's own pages, ADR 0057) answers
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and 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`, 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 `/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 `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 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 [The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
implicit `HEAD`) are also the ordinary `404`. 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 - **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive served files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
a `404` for it. a `404` for it.
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's 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 CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
`/api/v1/principal` says otherwise. `/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` | | `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
| `Cache-Control` | `no-cache` | `no-cache` | | `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-Content-Type-Options` | `nosniff` | `nosniff` |
| `X-Frame-Options` | `DENY` | — | | `X-Frame-Options` | `DENY` | — |
| `Content-Security-Policy` | `frame-ancestors 'none'` | — | | `Content-Security-Policy` | `frame-ancestors 'none'` | — |
| `Referrer-Policy` | `no-referrer` | — | | `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` 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 (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 `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`. 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":"[email protected]","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 ## Module UI inventory
```text ```text
+10 -1
View File
@@ -112,6 +112,8 @@ list:
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. | | `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.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.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 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 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 `core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
inside any module. 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 `core.modules.configuration.manage` is deliberately **separate from** the lifecycle permission
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)): deciding whether a ([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 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 `pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced — 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 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 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' 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 pattern; if discovery succeeded but composition failed, schema and migration checks still run
+4 -2
View File
@@ -136,7 +136,7 @@ Modules
✓ Dependency graph valid ✓ Dependency graph valid
Dependency order is empty. Dependency order is empty.
✓ Permission registry composed ✓ Permission registry composed
6 permissions registered 7 permissions registered
○ Module lifecycle skipped ○ Module lifecycle skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Module configuration skipped ○ Module configuration skipped
@@ -146,7 +146,7 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
7 migrations · checksums valid 9 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180 core:0002_authorization_integrity · 634294731180
@@ -154,6 +154,8 @@ Data definitions
core:0004_module_lifecycle_state · c8b5d8b3d48b core:0004_module_lifecycle_state · c8b5d8b3d48b
core:0005_module_configuration · c8d57f24f84b core:0005_module_configuration · c8d57f24f84b
core:0006_device_identity · 1db97b7e3ac5 core:0006_device_identity · 1db97b7e3ac5
core:0007_verified_bootstrap_owners · 49013062f3e0
core:0008_user_preferences · fa9d4b0bb9d3
PostgreSQL PostgreSQL
✓ Database TLS verified ✓ Database TLS verified
+22
View File
@@ -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 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. 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 ## What this platform must not claim
Ekklesia today has **none** of the following, and no document, README, or marketing surface may Ekklesia today has **none** of the following, and no document, README, or marketing surface may
+30 -3
View File
@@ -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 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, 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 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 shell's uses of browser storage are exactly two: the sidebar's expanded/collapsed mode
only by the entry point, one write of one fixed key, and no storage API in any file that holds the ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)) and the
reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)). 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 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: injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
+3
View File
@@ -55,6 +55,9 @@ importers:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.5.3 specifier: 2.5.3
version: 2.5.3 version: 2.5.3
'@ekklesia/design-tokens':
specifier: workspace:*
version: link:../../packages/design-tokens
'@ekklesia/module-ui-host': '@ekklesia/module-ui-host':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/module-ui-host version: link:../../packages/module-ui-host
+256
View File
@@ -102,10 +102,27 @@ const send = (method, params) => cdp.send(method, params, sessionId);
* `loadingFailed` here, never a response. */ * `loadingFailed` here, never a response. */
const responses = []; const responses = [];
const failed = []; 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) => { cdp.on((message) => {
if (message.sessionId !== sessionId) { if (message.sessionId !== sessionId) {
return; 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") { if (message.method === "Network.responseReceived") {
const { response, type } = message.params; const { response, type } = message.params;
responses.push({ status: response.status, type, url: response.url }); 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)); await new Promise((resolve) => setTimeout(resolve, 250));
} }
console.log(`FAIL timed out waiting for ${description}`); 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; failures += 1;
return false; return false;
} }
@@ -421,6 +469,208 @@ check(
"leaving Settings returns to the normal Dashboard navigation", "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 --------------- // --- Sign out on the Dashboard, from the account menu at the bottom of the sidebar ---------------
await navigate(`${dashboardOrigin}/`); await navigate(`${dashboardOrigin}/`);
await waitFor(selector("host-account-menu"), "the account control"); 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", credentials: "include",
}).then((response) => response.json()).then((body) => body.kind)`); }).then((response) => response.json()).then((body) => body.kind)`);
check(principal === "anonymous", "after sign-out, the API answers anonymous to the same browser"); 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 }); await cdp.send("Target.closeTarget", { targetId });
cdp.close(); cdp.close();
+56 -3
View File
@@ -73,8 +73,9 @@ async function edgeChecks() {
check( check(
page.headers.get("content-type") === "text/html; charset=utf-8" && page.headers.get("content-type") === "text/html; charset=utf-8" &&
html.includes('id="ekklesia-host"') && html.includes('id="ekklesia-host"') &&
html.includes('src="/assets/ekklesia-dashboard/app.js"'), html.includes('src="/assets/ekklesia-dashboard/app.js"') &&
"GET / is the Dashboard HTML", html.includes('<script src="/assets/ekklesia-dashboard/appearance.js"></script>'),
"GET / is the Dashboard HTML, with its pre-paint appearance script (ADR 0062)",
); );
check( check(
page.headers.get("x-frame-options") === "DENY" && 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 // 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. // 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}`); const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
check( check(
deepPage.status === 200 && deepPage.status === 200 &&
@@ -113,6 +119,17 @@ async function edgeChecks() {
await script.arrayBuffer(); await script.arrayBuffer();
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200"); 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`); const outside = await fetch(`${dashboardOrigin}/favicon.ico`);
check( check(
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"), 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`), await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
"the API hostname refuses the Dashboard's script", "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`); const display = await fetch(`${apiOrigin}/display/does-not-exist`);
check( check(
display.status === 404 && (await display.text()) === '{"code":"NOT_FOUND"}', 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 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`, { const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
body: "{}", body: "{}",
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin }, headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
+4 -1
View File
@@ -607,8 +607,11 @@ if [[ "$topology" == split ]]; then
docker rm --force "$browser_container" >/dev/null 2>&1 || true 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 # 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. # 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}" \ 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[@]}" \ --network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
"$browser_image" \ "$browser_image" \
"--ignore-certificate-errors-spki-list=${spki}" \ "--ignore-certificate-errors-spki-list=${spki}" \
+5
View File
@@ -10,6 +10,11 @@
<!-- Light and dark are both supported (ADR 0060). The browser paints its own canvas in the <!-- Light and dark are both supported (ADR 0060). The browser paints its own canvas in the
system's scheme before any script runs, so a dark system never flashes white first. --> system's scheme before any script runs, so a dark system never flashes white first. -->
<meta content="light dark" name="color-scheme" /> <meta content="light dark" name="color-scheme" />
<!-- The pre-paint appearance script (ADR 0062): classic, same-origin, and render-blocking on
purpose, so a person who chose Light or Dark sees it from the first frame. It reads this
browser's cached copy of their choice (two words, nothing about the account) and sets the
theme attributes and the colour scheme above. Never inline. -->
<script src="/assets/ekklesia-dashboard/appearance.js"></script>
<title>Ekklesia</title> <title>Ekklesia</title>
</head> </head>
<body <body
+24
View File
@@ -67,6 +67,27 @@ const buildOptions = {
target: "es2022", target: "es2022",
}; };
/**
* The Dashboard's pre-paint appearance script (ADR 0062): a **classic** script, not a module,
* because only a classic script in `<head>` 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 rm(outputDirectory, { force: true, recursive: true });
await mkdir(outputDirectory, { recursive: true }); await mkdir(outputDirectory, { recursive: true });
await mkdir(path.join(outputDirectory, "display"), { recursive: true }); await mkdir(path.join(outputDirectory, "display"), { recursive: true });
@@ -87,6 +108,8 @@ await copyFile(
); );
if (serve) { if (serve) {
const appearanceBootContext = await esbuild.context(appearanceBootOptions);
await appearanceBootContext.watch();
const context = await esbuild.context(buildOptions); const context = await esbuild.context(buildOptions);
await context.watch(); await context.watch();
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as // 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 { } else {
await esbuild.build(buildOptions); await esbuild.build(buildOptions);
await esbuild.build(appearanceBootOptions);
console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`); console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`);
} }
@@ -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<HostAccount, "email" | "name">): 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<HostAccount, "email" | "name">): 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<HostAccount, "email" | "name">;
readonly size?: "md" | "lg";
}): ReactElement {
const size = props.size === "lg" ? "3rem" : "1.75rem";
return (
<span
aria-hidden="true"
className="ek-shell-avatar"
data-testid="account-avatar"
style={{
alignItems: "center",
background: designToken("accent.subtle"),
borderRadius: designToken("radius.pill"),
color: designToken("text.primary"),
display: "inline-flex",
flex: "0 0 auto",
fontSize:
props.size === "lg"
? designToken("typography.size-lg")
: designToken("typography.size-xs"),
fontWeight: designToken("typography.weight-bold"),
height: size,
justifyContent: "center",
lineHeight: 1,
width: size,
}}
>
{accountInitials(props.account)}
</span>
);
}
+125 -44
View File
@@ -7,22 +7,43 @@ import {
useRef, useRef,
useState, useState,
} from "react"; } 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 { useNavTooltip } from "./NavTooltip.js";
import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js"; import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js";
import { ShellIcon, type ShellIconName } from "./shell-icons.js"; import { ShellIcon, type ShellIconName } from "./shell-icons.js";
export interface AccountMenuProps { export interface AccountMenuProps {
readonly presentation: NavigationPresentation; 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; readonly onSignOut: () => void;
/** Called after choosing a destination, so a drawer around the menu can close. */
readonly onNavigate?: () => void;
} }
interface AccountAction { type AccountAction =
readonly id: string; | {
readonly label: string; readonly kind: "link";
readonly icon: ShellIconName; readonly id: string;
readonly testId: string; readonly label: string;
readonly run: () => void; 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). * 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 * returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it
* went. * went.
* *
* It offers **only actions that work today** — Sign out. Profile, personal preferences, and * It shows **who is signed in** — their name and email beside their initials (ADR 0062) — and
* appearance belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages; until then there * offers only destinations that work: Account (`/account`), Appearance (`/account/appearance`), and
* is no dead link to `/account`. The shell knows the person only by an opaque user id * Sign out. Account and Appearance are real links, so they open in a new tab like any other. When
* (`/api/v1/principal`), which is never shown, so the control names the area rather than the person. * the account cannot be loaded, the control names the area ("Your account") rather than the person.
*/ */
export function AccountMenu(props: AccountMenuProps): ReactElement { export function AccountMenu(props: AccountMenuProps): ReactElement {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first"); const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); const itemRefs = useRef<(HTMLElement | null)[]>([]);
const menuId = useId(); const menuId = useId();
const triggerId = useId(); const triggerId = useId();
const { collapsed } = props.presentation; 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) => { const close = useCallback((returnFocus: boolean) => {
setOpen(false); setOpen(false);
@@ -57,9 +80,26 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
}, []); }, []);
const actions: readonly AccountAction[] = [ 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", icon: "signOut",
id: "sign-out", id: "sign-out",
kind: "button",
label: "Sign out", label: "Sign out",
run: () => { run: () => {
setOpen(false); setOpen(false);
@@ -78,7 +118,7 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
if (!open) { if (!open) {
return; 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 === "last" ? items.at(-1) : items[0])?.focus();
}, [initialFocus, open]); }, [initialFocus, open]);
@@ -98,8 +138,8 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
}, [open]); }, [open]);
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => { const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null); const items = itemRefs.current.filter((item): item is HTMLElement => item !== null);
const index = items.indexOf(event.target as HTMLButtonElement); const index = items.indexOf(event.target as HTMLElement);
const focusAt = (next: number): void => { const focusAt = (next: number): void => {
event.preventDefault(); event.preventDefault();
items[(next + items.length) % items.length]?.focus(); items[(next + items.length) % items.length]?.focus();
@@ -125,32 +165,68 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
return ( return (
<div ref={wrapperRef}> <div ref={wrapperRef}>
{open ? ( {open ? (
<div <div className="ek-shell-menu" data-testid="host-account-menu-popover">
aria-labelledby={triggerId} {account ? (
className="ek-shell-menu" <div className="ek-shell-menu__identity" data-testid="host-account-identity">
data-testid="host-account-menu-list" <span className="ek-shell-menu__identity-name">{accountDisplayName(account)}</span>
id={menuId} {account.name.trim().length > 0 ? (
onKeyDown={onMenuKeyDown} <span className="ek-shell-menu__identity-email">{account.email}</span>
role="menu" ) : undefined}
> </div>
{actions.map((action, index) => ( ) : undefined}
<button <div
className="ek-button ek-focusable ek-button--quiet ek-shell-menu__item" aria-labelledby={triggerId}
data-testid={action.testId} className="ek-shell-menu__items"
key={action.id} data-testid="host-account-menu-list"
onClick={action.run} id={menuId}
ref={(element) => { onKeyDown={onMenuKeyDown}
role="menu"
>
{actions.map((action, index) => {
const ref = (element: HTMLElement | null): void => {
itemRefs.current[index] = element; itemRefs.current[index] = element;
}} };
role="menuitem" const content = (
style={{ minHeight: controlHeight(props.presentation) }} <>
tabIndex={-1} <ShellIcon name={action.icon} />
type="button" {action.label}
> </>
<ShellIcon name={action.icon} /> );
{action.label} return action.kind === "link" ? (
</button> <DashboardLink
))} className="ek-shell-menu__item"
data-testid={action.testId}
history={props.history}
key={action.id}
onNavigate={() => {
setOpen(false);
props.onNavigate?.();
}}
ref={ref}
role="menuitem"
style={{ minHeight: controlHeight(props.presentation) }}
tabIndex={-1}
to={action.to}
>
{content}
</DashboardLink>
) : (
<button
className="ek-button ek-focusable ek-button--quiet ek-shell-menu__item"
data-testid={action.testId}
key={action.id}
onClick={action.run}
ref={ref}
role="menuitem"
style={{ minHeight: controlHeight(props.presentation) }}
tabIndex={-1}
type="button"
>
{content}
</button>
);
})}
</div>
</div> </div>
) : undefined} ) : undefined}
<button <button
@@ -176,9 +252,14 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
} }
}} }}
> >
<ShellIcon name="account" /> {account ? <AccountAvatar account={account} /> : <ShellIcon name="account" />}
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"}> <span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-account-trigger__copy"}>
Your account <span className="ek-shell-nav-item__label">{name}</span>
{account && !collapsed && account.name.trim().length > 0 ? (
<span className="ek-shell-nav-item__label ek-shell-account-trigger__email">
{account.email}
</span>
) : undefined}
</span> </span>
</button> </button>
{tooltip} {tooltip}
+359
View File
@@ -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 (
<div data-testid="account-home" style={{ display: "grid", gap: designToken("spacing.xl") }}>
<PageHeader description="Who you're signed in as, and your own settings." title="Account" />
<Panel title="Signed in as">
<div
data-testid="account-identity"
style={{
alignItems: "center",
display: "flex",
flexWrap: "wrap",
gap: designToken("spacing.md"),
minWidth: 0,
}}
>
<AccountAvatar account={account} size="lg" />
<dl
style={{
display: "grid",
gap: designToken("spacing.2xs"),
margin: 0,
minWidth: 0,
}}
>
{hasName ? (
<div>
<dt className="ek-shell-visually-hidden">Name</dt>
<dd
data-testid="account-name"
style={{
fontWeight: designToken("typography.weight-medium"),
margin: 0,
overflowWrap: "anywhere",
}}
>
{accountDisplayName(account)}
</dd>
</div>
) : undefined}
<div>
<dt className="ek-shell-visually-hidden">Email</dt>
<dd
data-testid="account-email"
style={{
color: hasName ? designToken("text.secondary") : undefined,
margin: 0,
overflowWrap: "anywhere",
}}
>
{account.email}
</dd>
</div>
</dl>
</div>
</Panel>
<Panel title="Your settings">
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
<li>
<DashboardLink
className="ek-shell-settings-link"
data-testid="account-home-appearance"
history={props.history}
style={{ minHeight: designToken("control.height-lg") }}
to={APPEARANCE}
>
<ShellIcon name="appearance" />
<span className="ek-shell-settings-link__copy">
<span className="ek-shell-settings-link__label">Appearance</span>
<span className="ek-shell-settings-link__description">
Light, dark, or matching your device, and how much the Dashboard moves.
</span>
</span>
</DashboardLink>
</li>
</ul>
</Panel>
<div>
<Button data-testid="account-sign-out" onClick={props.onSignOut} variant="secondary">
<ShellIcon name="signOut" />
Sign out
</Button>
</div>
</div>
);
}
interface ChoiceOption<Value extends string> {
readonly value: Value;
readonly label: string;
readonly description: string;
}
const APPEARANCE_OPTIONS: readonly ChoiceOption<HostAccountPreferences["appearance"]>[] = [
{ 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<HostAccountPreferences["motion"]>[] = [
{
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 `<fieldset>` named by its `<legend>`, each option a real
* `<input type="radio">` 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<Value extends string>(props: {
readonly name: string;
readonly legend: string;
readonly description: string;
readonly options: readonly ChoiceOption<Value>[];
readonly value: Value;
readonly onChange: (value: Value) => void;
}): ReactElement {
const descriptionId = useId();
return (
<fieldset
aria-describedby={descriptionId}
className="ek-shell-choice-group"
data-testid={`appearance-${props.name}`}
>
<legend className="ek-shell-choice-group__legend">{props.legend}</legend>
<p className="ek-shell-choice-group__description" id={descriptionId}>
{props.description}
</p>
<div className="ek-shell-choice-group__options">
{props.options.map((option) => {
const checked = option.value === props.value;
return (
<label
className="ek-shell-choice"
data-checked={checked ? "true" : undefined}
data-testid={`appearance-${props.name}-${option.value}`}
key={option.value}
style={{ minHeight: designToken("control.height-lg") }}
>
<input
checked={checked}
className="ek-shell-choice__input ek-focusable"
name={props.name}
onChange={() => props.onChange(option.value)}
type="radio"
value={option.value}
/>
<span className="ek-shell-choice__copy">
<span className="ek-shell-choice__label">{option.label}</span>
<span className="ek-shell-choice__description">{option.description}</span>
</span>
</label>
);
})}
</div>
</fieldset>
);
}
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<SaveStatus>({ kind: "idle" });
// What the account last confirmed: the value a failed save is put back to.
const confirmed = useRef<HostAccountPreferences>(props.account.preferences);
const sequence = useRef(0);
const inFlight = useRef(new Set<AbortController>());
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<HostAccountPreferences>): 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 (
<div
data-testid="account-appearance"
style={{ display: "grid", gap: designToken("spacing.xl") }}
>
<PageHeader
back={dashboardBack(props.history, {
fallback: ACCOUNT,
label: (destination) => (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"
/>
<Panel>
<div style={{ display: "grid", gap: designToken("spacing.xl") }}>
<ChoiceGroup
description="Light or dark, or the same as your device."
legend="Appearance"
name="appearance"
onChange={(value) => choose({ appearance: value })}
options={APPEARANCE_OPTIONS}
value={current.appearance}
/>
<ChoiceGroup
description="Animation and transitions in the Dashboard."
legend="Reduced motion"
name="motion"
onChange={(value) => choose({ motion: value })}
options={MOTION_OPTIONS}
value={current.motion}
/>
</div>
</Panel>
<div aria-live="polite" data-testid="appearance-status">
{status.kind === "saved" ? (
<span className="ek-shell-visually-hidden">Saved to your account.</span>
) : undefined}
{status.kind === "failed" ? (
<InlineNotice title="Your choice wasn't saved" tone="danger">
It's back to what your account had. Check your connection and try again.
</InlineNotice>
) : undefined}
</div>
</div>
);
}
/** 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 (
<div data-testid="account-load-failed">
<ErrorState
actions={<Button onClick={props.onRetry}>Retry</Button>}
description="The rest of the Dashboard still works. Try again in a moment."
title="Your account could not be loaded"
/>
</div>
);
}
@@ -7,6 +7,7 @@ import type {
MouseEventHandler, MouseEventHandler,
ReactElement, ReactElement,
ReactNode, ReactNode,
Ref,
} from "react"; } from "react";
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js"; import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
import type { HostHistory } from "./host-history.js"; import type { HostHistory } from "./host-history.js";
@@ -29,6 +30,11 @@ export interface DashboardLinkProps {
readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>; readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>; readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
readonly "data-testid"?: string; 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<HTMLAnchorElement>;
} }
const variantClass = { const variantClass = {
@@ -100,7 +106,10 @@ export function DashboardLink(props: DashboardLinkProps): ReactElement {
onKeyDown={props.onKeyDown} onKeyDown={props.onKeyDown}
onMouseEnter={props.onMouseEnter} onMouseEnter={props.onMouseEnter}
onMouseLeave={props.onMouseLeave} onMouseLeave={props.onMouseLeave}
ref={props.ref}
role={props.role}
style={props.style ? { ...baseStyle, ...props.style } : baseStyle} style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
tabIndex={props.tabIndex}
> >
{props.children} {props.children}
</a> </a>
+84 -8
View File
@@ -25,10 +25,17 @@ import {
bootstrapHost, bootstrapHost,
type DashboardNavigationEntry, type DashboardNavigationEntry,
type EkklesiaHostConfig, type EkklesiaHostConfig,
type HostAccountPreferences,
type HostBootstrap, type HostBootstrap,
HostProtocolError, HostProtocolError,
loadHostPrincipal, loadHostPrincipal,
} from "../host-protocol.js"; } 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 { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import type { HostHistory } from "./host-history.js"; import type { HostHistory } from "./host-history.js";
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.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. * passes this browser's storage; left unset, the mode lasts only as long as this component.
*/ */
readonly sidebarPreference?: SidebarPreference; 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. */ /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter; 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. // Fallbacks for a composition that supplies neither; created once, so they stay stable.
const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined)); const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined));
const [fallbackPreference] = useState(() => createMemorySidebarPreference()); const [fallbackPreference] = useState(() => createMemorySidebarPreference());
const [fallbackAppearance] = useState(() =>
createDetachedAppearancePreference(createMemoryAppearanceCache()),
);
const viewport = props.viewport ?? fallbackViewport; const viewport = props.viewport ?? fallbackViewport;
const sidebarPreference = props.sidebarPreference ?? fallbackPreference; const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
const appearance = props.appearance ?? fallbackAppearance;
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]); const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
const readPathname = useCallback(() => history.pathname(), [history]); const readPathname = useCallback(() => history.pathname(), [history]);
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname); 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 // 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 // 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. // again, describing an emailed link the person has long since finished with.
setAuthView({ kind: "sign-in" }); setAuthView({ kind: "sign-in" });
setState( if (principal.kind === "anonymous") {
principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" }, appearance.forget();
); setState({ status: "anonymous" });
} else {
setState({ status: "sign-out-failed" });
}
} catch { } catch {
if (isCurrent(controller, generation)) { if (isCurrent(controller, generation)) {
console.error("[ekklesia] sign out failed"); console.error("[ekklesia] sign out failed");
@@ -410,7 +461,7 @@ export function HostApp(props: HostAppProps): ReactElement {
activeTransitionRef.current = undefined; activeTransitionRef.current = undefined;
} }
} }
}, [beginTransition, config, isCurrent]); }, [appearance, beginTransition, config, isCurrent]);
const onAuthorizationResponse = useCallback( const onAuthorizationResponse = useCallback(
(status: 401 | 403): void => { (status: 401 | 403): void => {
@@ -440,6 +491,7 @@ export function HostApp(props: HostAppProps): ReactElement {
transition.controller.abort(); transition.controller.abort();
activeTransitionRef.current = undefined; activeTransitionRef.current = undefined;
setAuthView({ kind: "sign-in" }); setAuthView({ kind: "sign-in" });
appearance.forget();
setState({ status: "anonymous" }); setState({ status: "anonymous" });
} }
}) })
@@ -463,7 +515,7 @@ export function HostApp(props: HostAppProps): ReactElement {
} }
}); });
}, },
[beginTransition, config], [appearance, beginTransition, config],
); );
const openEntry = const openEntry =
@@ -491,7 +543,11 @@ export function HostApp(props: HostAppProps): ReactElement {
? "Overview" ? "Overview"
: route.kind === "module" : route.kind === "module"
? (openEntry?.name ?? "This page isn’t available") ? (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; const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
useEffect(() => { useEffect(() => {
@@ -609,6 +665,7 @@ export function HostApp(props: HostAppProps): ReactElement {
config={config} config={config}
history={history} history={history}
mainRef={mainRef} mainRef={mainRef}
account={bootstrap.account}
navigation={bootstrap.navigation} navigation={bootstrap.navigation}
onSignOut={() => void signOut()} onSignOut={() => void signOut()}
pageTitle={pageTitle} pageTitle={pageTitle}
@@ -619,6 +676,25 @@ export function HostApp(props: HostAppProps): ReactElement {
> >
{settingsPage !== undefined ? ( {settingsPage !== undefined ? (
<SettingsPageView history={history} onRetry={retryBootstrap} page={settingsPage} /> <SettingsPageView history={history} onRetry={retryBootstrap} page={settingsPage} />
) : route.kind === "account" || route.kind === "account-section" ? (
bootstrap.account === "unavailable" ? (
<AccountLoadFailedPage onRetry={retryBootstrap} />
) : route.kind === "account" ? (
<AccountHomePage
account={bootstrap.account}
history={history}
onSignOut={() => void signOut()}
/>
) : (
<AppearancePage
account={bootstrap.account}
appearance={appearance}
config={config}
history={history}
onSaved={recordSavedPreferences}
onSessionCheck={() => onAuthorizationResponse(403)}
/>
)
) : route.kind === "overview" ? ( ) : route.kind === "overview" ? (
<OverviewPage /> <OverviewPage />
) : route.kind === "module" ? ( ) : route.kind === "module" ? (
+19 -2
View File
@@ -16,6 +16,7 @@ import type {
DashboardNavigation, DashboardNavigation,
DashboardSettings, DashboardSettings,
EkklesiaHostConfig, EkklesiaHostConfig,
HostAccount,
} from "../host-protocol.js"; } from "../host-protocol.js";
import { AccountMenu } from "./AccountMenu.js"; import { AccountMenu } from "./AccountMenu.js";
import { DevicePanel } from "./DevicePanel.js"; import { DevicePanel } from "./DevicePanel.js";
@@ -45,6 +46,7 @@ export interface ShellLayoutProps {
readonly history: HostHistory; readonly history: HostHistory;
readonly navigation: DashboardNavigation; readonly navigation: DashboardNavigation;
readonly settings: DashboardSettings | "unavailable"; readonly settings: DashboardSettings | "unavailable";
readonly account: HostAccount | "unavailable";
readonly route: DashboardRoute; readonly route: DashboardRoute;
readonly viewport: HostViewport; readonly viewport: HostViewport;
readonly sidebarPreference: SidebarPreference; readonly sidebarPreference: SidebarPreference;
@@ -89,6 +91,7 @@ function Sidebar(props: {
readonly history: HostHistory; readonly history: HostHistory;
readonly navigation: DashboardNavigation; readonly navigation: DashboardNavigation;
readonly settings: DashboardSettings | "unavailable"; readonly settings: DashboardSettings | "unavailable";
readonly account: HostAccount | "unavailable";
readonly route: DashboardRoute; readonly route: DashboardRoute;
readonly mode: SidebarMode; readonly mode: SidebarMode;
readonly onToggle: () => void; readonly onToggle: () => void;
@@ -123,7 +126,12 @@ function Sidebar(props: {
{presentation.collapsed ? undefined : <DevicePanel config={props.config} />} {presentation.collapsed ? undefined : <DevicePanel config={props.config} />}
</div> </div>
<div className="ek-shell-sidebar__bottom"> <div className="ek-shell-sidebar__bottom">
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} /> <AccountMenu
account={props.account}
history={props.history}
onSignOut={props.onSignOut}
presentation={presentation}
/>
</div> </div>
</aside> </aside>
); );
@@ -143,6 +151,7 @@ function NavigationDrawer(props: {
readonly history: HostHistory; readonly history: HostHistory;
readonly navigation: DashboardNavigation; readonly navigation: DashboardNavigation;
readonly settings: DashboardSettings | "unavailable"; readonly settings: DashboardSettings | "unavailable";
readonly account: HostAccount | "unavailable";
readonly route: DashboardRoute; readonly route: DashboardRoute;
readonly onDismiss: () => void; readonly onDismiss: () => void;
readonly onNavigate: () => void; readonly onNavigate: () => void;
@@ -226,7 +235,13 @@ function NavigationDrawer(props: {
<DevicePanel config={props.config} /> <DevicePanel config={props.config} />
</div> </div>
<div className="ek-shell-sidebar__bottom"> <div className="ek-shell-sidebar__bottom">
<AccountMenu onSignOut={props.onSignOut} presentation={presentation} /> <AccountMenu
account={props.account}
history={props.history}
onNavigate={props.onNavigate}
onSignOut={props.onSignOut}
presentation={presentation}
/>
</div> </div>
</div> </div>
</> </>
@@ -325,6 +340,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
<style>{SHELL_STYLESHEET}</style> <style>{SHELL_STYLESHEET}</style>
{compact ? undefined : ( {compact ? undefined : (
<Sidebar <Sidebar
account={props.account}
config={props.config} config={props.config}
history={props.history} history={props.history}
mode={mode} mode={mode}
@@ -376,6 +392,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
</div> </div>
{showDrawer ? ( {showDrawer ? (
<NavigationDrawer <NavigationDrawer
account={props.account}
config={props.config} config={props.config}
history={props.history} history={props.history}
navigation={props.navigation} navigation={props.navigation}
@@ -0,0 +1,38 @@
import { APPEARANCE_CACHE_KEY, parseAppearanceCache } from "./appearance-cache.js";
/**
* The Dashboard's pre-paint appearance script (ADR 0062), served at
* `/assets/ekklesia-dashboard/appearance.js`.
*
* A classic, render-blocking, same-origin script in the page's `<head>` — 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
* `<meta name="color-scheme">`, 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.
}
@@ -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: `<appearance> <motion>`, 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<Storage, "getItem" | "setItem" | "removeItem"> | 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;
},
};
}
@@ -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;
/** `<meta name="color-scheme">`, narrowed with an explicit choice exactly as the pre-paint script
* narrows it, so the two never disagree. */
readonly colorSchemeMeta?: Pick<EkklesiaThemeTarget, "setAttribute">;
}
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<string, string>();
return createAppearancePreference({
cache,
theme: createEkklesiaThemeController({
appearance: initial.appearance,
motion: initial.motion,
target: { setAttribute: (name, value) => attributes.set(name, value) },
}),
});
}
+54 -26
View File
@@ -6,6 +6,8 @@ import { EkklesiaUiStyles } from "@ekklesia/ui";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { consumeAuthLanding } from "../auth-protocol.js"; import { consumeAuthLanding } from "../auth-protocol.js";
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-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 { HostApp } from "./HostApp.js";
import { createBrowserHostHistory } from "./host-history.js"; import { createBrowserHostHistory } from "./host-history.js";
import { createBrowserHostViewport } from "./host-viewport.js"; import { createBrowserHostViewport } from "./host-viewport.js";
@@ -36,21 +38,25 @@ const setDocumentTitle = (title: string): void => {
document.title = title; 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 * This browser's storage, read once. It holds exactly two things: the desktop sidebar's mode
* mode for this page only. */ * (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( const viewport = createBrowserHostViewport(
typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined, typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined,
); );
const sidebarPreference = createLocalSidebarPreference( const sidebarPreference = createLocalSidebarPreference(browserStorage);
(() => {
try {
return window.localStorage;
} catch {
return undefined;
}
})(),
);
const container = document.getElementById("ekklesia-host"); const container = document.getElementById("ekklesia-host");
if (!container) { if (!container) {
@@ -69,22 +75,42 @@ if (!container) {
installEkklesiaDesignTokens(document); installEkklesiaDesignTokens(document);
/** /**
* The Dashboard's appearance and motion (ADR 0060), applied to the document root before React * The Dashboard's appearance and motion (ADR 0060, ADR 0062), applied to the document root before
* renders anything. * React renders anything.
* *
* Both are `system` until a person chooses otherwise: the token stylesheet then follows the * The page's pre-paint script (`appearance-boot.ts`) has already put this browser's cached choice
* operating system's light/dark and reduced-motion settings live, by CSS alone, into every module * on the root, before the body was parsed; the controller starts from that same cache, so the two
* root. This is the one place a remembered choice will be supplied: the account preferences work * agree and nothing changes here. Without a cache both are `system`, and the token stylesheet
* reads it synchronously and passes it here, so an explicit Light or Dark is in place before the * follows the operating system's light/dark and reduced-motion settings live, by CSS alone, into
* first frame. Nothing is stored yet, and there is no second, temporary way to choose. * every module root. Once the account answers, the shell applies what it holds (`HostApp`).
*/ */
createEkklesiaThemeController({ const appearanceCache = createLocalAppearanceCache(browserStorage);
appearance: "system", const cachedAppearance = appearanceCache.read();
...(typeof window.matchMedia === "function" const appearance = createAppearancePreference({
? { matchMedia: (query: string) => window.matchMedia(query) } cache: appearanceCache,
: {}), ...(() => {
motion: "system", const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]');
target: document.documentElement, 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 ? ( return authLanding ? (
<HostApp <HostApp
appearance={appearance}
authLanding={authLanding} authLanding={authLanding}
config={config} config={config}
history={history} history={history}
@@ -111,6 +138,7 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
/> />
) : ( ) : (
<HostApp <HostApp
appearance={appearance}
config={config} config={config}
history={history} history={history}
setDocumentTitle={setDocumentTitle} setDocumentTitle={setDocumentTitle}
@@ -16,6 +16,7 @@ import {
Server, Server,
Settings, Settings,
SlidersHorizontal, SlidersHorizontal,
SunMoon,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
@@ -34,6 +35,7 @@ import type { ReactElement } from "react";
*/ */
export const SHELL_ICONS = Object.freeze({ export const SHELL_ICONS = Object.freeze({
account: CircleUserRound, account: CircleUserRound,
appearance: SunMoon,
closeNavigation: X, closeNavigation: X,
collapseSidebar: PanelLeftClose, collapseSidebar: PanelLeftClose,
discoverBrowse: Search, discoverBrowse: Search,
+123
View File
@@ -209,9 +209,12 @@ export const SHELL_STYLESHEET = `
gap: ${designToken("spacing.2xs")}; gap: ${designToken("spacing.2xs")};
left: 0; left: 0;
margin: 0; margin: 0;
max-width: min(20rem, calc(100vw - 2 * ${designToken("spacing.md")}));
min-width: 12rem; min-width: 12rem;
padding: ${designToken("spacing.xs")}; padding: ${designToken("spacing.xs")};
position: absolute; position: absolute;
/* As wide as a name and email need, within the cap, rather than the width of the sidebar. */
width: max-content;
z-index: 20; z-index: 20;
} }
@@ -227,6 +230,126 @@ export const SHELL_STYLESHEET = `
width: 100%; width: 100%;
} }
.ek-shell-menu__items {
display: grid;
gap: ${designToken("spacing.2xs")};
}
.ek-shell-menu__identity {
border-bottom: ${designToken("border.width")} solid ${designToken("border.divider")};
display: grid;
gap: ${designToken("spacing.2xs")};
min-width: 0;
padding: ${designToken("spacing.xs")} ${designToken("spacing.sm")} ${designToken("spacing.sm")};
}
.ek-shell-menu__identity-name {
color: ${designToken("text.primary")};
font-weight: ${designToken("typography.weight-medium")};
overflow-wrap: anywhere;
}
.ek-shell-menu__identity-email {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
overflow-wrap: anywhere;
}
.ek-shell-account-trigger__copy {
display: grid;
min-width: 0;
text-align: start;
}
.ek-shell-choice-group {
border: 0;
margin: 0;
min-width: 0;
padding: 0;
}
.ek-shell-choice-group__legend {
color: ${designToken("text.primary")};
font-size: ${designToken("typography.size-heading-3")};
font-weight: ${designToken("typography.weight-bold")};
padding: 0;
}
.ek-shell-choice-group__description {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: ${designToken("spacing.2xs")} 0 ${designToken("spacing.md")};
}
.ek-shell-choice-group__options {
display: grid;
gap: ${designToken("spacing.xs")};
}
/* One option of a radio group: the whole row is the label, so the whole row is the touch target.
The chosen option is marked by the radio itself and by weight as well as by its background. */
.ek-shell-choice {
align-items: flex-start;
border: ${designToken("border.width")} solid ${designToken("border.control")};
border-radius: ${designToken("radius.md")};
box-sizing: border-box;
color: ${designToken("text.primary")};
cursor: pointer;
display: flex;
gap: ${designToken("spacing.md")};
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")};
}
.ek-shell-choice:hover {
background: ${designToken("state.hover-background")};
}
.ek-shell-choice:has(.ek-shell-choice__input:focus-visible) {
outline: ${designToken("state.focus-visible-width")} solid ${designToken("accent.focus")};
outline-offset: ${designToken("state.focus-visible-offset")};
}
.ek-shell-choice[data-checked="true"] {
background: ${designToken("state.selected-background")};
color: ${designToken("state.selected-foreground")};
}
.ek-shell-choice__input {
accent-color: ${designToken("accent.base")};
flex: 0 0 auto;
height: 1.25rem;
margin: 0.125rem 0 0;
width: 1.25rem;
}
.ek-shell-choice__copy {
display: grid;
gap: ${designToken("spacing.2xs")};
min-width: 0;
}
.ek-shell-choice__label {
font-weight: ${designToken("typography.weight-medium")};
}
.ek-shell-choice[data-checked="true"] .ek-shell-choice__label {
font-weight: ${designToken("typography.weight-bold")};
}
.ek-shell-choice__description {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
}
.ek-shell-choice[data-checked="true"] .ek-shell-choice__description {
color: ${designToken("state.selected-foreground")};
}
.ek-shell-account-trigger__email {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-xs")};
}
.ek-shell-mobile-bar { .ek-shell-mobile-bar {
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0; padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
} }
+4
View File
@@ -6,10 +6,14 @@
* selectors. `apps/api` reads these exact two files before binding and serves their bytes at fixed * selectors. `apps/api` reads these exact two files before binding and serves their bytes at fixed
* host-owned routes on the same origin as `/api`. Nothing here exposes a directory or a generic * host-owned routes on the same origin as `/api`. Nothing here exposes a directory or a generic
* static-file root, and the source map beside the script is deliberately not listed. * static-file root, and the source map beside the script is deliberately not listed.
*
* `appearance` is the small classic script the page loads in `<head>` before anything paints, which
* applies this browser's cached appearance (ADR 0062).
*/ */
const packageRoot = new URL("../", import.meta.url); const packageRoot = new URL("../", import.meta.url);
export const dashboardHostAssetUrls = Object.freeze({ export const dashboardHostAssetUrls = Object.freeze({
html: new URL("dist/browser/index.html", packageRoot).href, html: new URL("dist/browser/index.html", packageRoot).href,
script: new URL("dist/browser/assets/ekklesia-dashboard/app.js", 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,
}); });
+37 -1
View File
@@ -13,7 +13,8 @@
* /settings/<section> a platform Settings section: general, branding, displays, system * /settings/<section> a platform Settings section: general, branding, displays, system
* /settings/discover/<view> finding, installing, and managing modules: browse, installed, updates * /settings/discover/<view> finding, installing, and managing modules: browse, installed, updates
* /settings/<module-id> a module's settings (and later its own deeper settings pages) * /settings/<module-id> a module's settings (and later its own deeper settings pages)
* /account[/…] the account area * /account your account
* /account/<section> a personal settings page: appearance
* /<module-id>[/…] a module's page, and later its own sub-pages * /<module-id>[/…] 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. */ /** The sections that are one page each. Discover is a group of views instead. */
export type SettingsPageSection = Exclude<SettingsSection, "discover">; export type SettingsPageSection = Exclude<SettingsSection, "discover">;
/**
* 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 * 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 * 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: "settings-section"; readonly section: SettingsPageSection }
| { readonly kind: "discover"; readonly view: DiscoverView } | { readonly kind: "discover"; readonly view: DiscoverView }
| { readonly kind: "module-settings"; readonly moduleId: string } | { readonly kind: "module-settings"; readonly moduleId: string }
| { readonly kind: "account" }
| { readonly kind: "account-section"; readonly section: AccountSection }
| { readonly kind: "not-found" }; | { readonly kind: "not-found" };
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */ /** 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<string> = new Set(DASHBOARD_PLATFORM_PAGES);
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS); const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS); const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
const accountSections: ReadonlySet<string> = new Set(ACCOUNT_SECTIONS);
function isSettingsPageSection(value: string): value is SettingsPageSection { function isSettingsPageSection(value: string): value is SettingsPageSection {
return value !== "discover" && settingsSections.has(value); return value !== "discover" && settingsSections.has(value);
@@ -85,6 +98,10 @@ function isDiscoverView(value: string): value is DiscoverView {
return discoverViews.has(value); return discoverViews.has(value);
} }
function isAccountSection(value: string): value is AccountSection {
return accountSections.has(value);
}
function decodeSegment(segment: string): string | undefined { function decodeSegment(segment: string): string | undefined {
try { try {
const decoded = decodeURIComponent(segment); const decoded = decodeURIComponent(segment);
@@ -120,6 +137,9 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
if (first === "settings") { if (first === "settings") {
return parseSettingsPath(meaningful.slice(1)); return parseSettingsPath(meaningful.slice(1));
} }
if (first === "account") {
return parseAccountPath(meaningful.slice(1));
}
if (first === undefined || platformPages.has(first)) { if (first === undefined || platformPages.has(first)) {
return { kind: "not-found" }; return { kind: "not-found" };
} }
@@ -161,6 +181,18 @@ function parseSettingsPath(rest: readonly string[]): DashboardRoute {
: { kind: "module-settings", moduleId: name }; : { 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 /** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
* own path segment. */ * own path segment. */
export function dashboardPath(route: DashboardDestination): string { export function dashboardPath(route: DashboardDestination): string {
@@ -177,5 +209,9 @@ export function dashboardPath(route: DashboardDestination): string {
return `/settings/discover/${route.view}`; return `/settings/discover/${route.view}`;
case "module-settings": case "module-settings":
return `/settings/${encodeURIComponent(route.moduleId)}`; return `/settings/${encodeURIComponent(route.moduleId)}`;
case "account":
return "/account";
case "account-section":
return `/account/${route.section}`;
} }
} }
+106 -5
View File
@@ -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 type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js";
/** /**
* How this shell reaches the Ekklesia API. * 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_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings"; const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings";
const MODULE_DISPLAYS_PATH = "/api/v1/module-displays"; 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_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const MODULE_ID_MAX_LENGTH = 64; const MODULE_ID_MAX_LENGTH = 64;
@@ -404,6 +412,7 @@ async function requestPlatformMutation(
rootRelativePath: string, rootRelativePath: string,
body: unknown, body: unknown,
signal?: AbortSignal, signal?: AbortSignal,
method: "PATCH" | "POST" = "POST",
): Promise<unknown> { ): Promise<unknown> {
const performRequest = config.fetch ?? fetch; const performRequest = config.fetch ?? fetch;
let response: Response; let response: Response;
@@ -413,7 +422,7 @@ async function requestPlatformMutation(
cache: "no-store", cache: "no-store",
credentials: "include", credentials: "include",
headers: { accept: "application/json", "content-type": "application/json" }, headers: { accept: "application/json", "content-type": "application/json" },
method: "POST", method,
redirect: "error", redirect: "error",
...(signal ? { signal } : {}), ...(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<HostAccount> {
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<HostAccountPreferences>,
signal?: AbortSignal,
): Promise<HostAccountPreferences> {
return parseHostAccountPreferences(
await requestPlatformMutation(config, ACCOUNT_PREFERENCES_PATH, update, signal, "PATCH"),
);
}
export interface HostBootstrap { export interface HostBootstrap {
readonly principal: ModuleUiPrincipal; readonly principal: ModuleUiPrincipal;
readonly navigation: DashboardNavigation; readonly navigation: DashboardNavigation;
@@ -944,6 +1026,13 @@ export interface HostBootstrap {
* is withheld and its pages say they could not be loaded. * is withheld and its pages say they could not be loaded.
*/ */
readonly settings: DashboardSettings | "unavailable"; 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({ const EMPTY_SETTINGS: DashboardSettings = Object.freeze({
@@ -968,9 +1057,14 @@ export async function bootstrapHost(
): Promise<HostBootstrap> { ): Promise<HostBootstrap> {
const principal = await loadHostPrincipal(config, signal); const principal = await loadHostPrincipal(config, signal);
if (principal.kind === "anonymous") { 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), loadDashboardNavigation(config, signal),
loadDashboardSettings(config, signal).catch((error: unknown) => { loadDashboardSettings(config, signal).catch((error: unknown) => {
if (signal?.aborted) { if (signal?.aborted) {
@@ -979,6 +1073,13 @@ export async function bootstrapHost(
console.error("[ekklesia] settings navigation unavailable", error); console.error("[ekklesia] settings navigation unavailable", error);
return "unavailable" as const; 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 };
} }
+6
View File
@@ -19,6 +19,8 @@ export {
type DashboardNavigationGroup, type DashboardNavigationGroup,
type DisplayHostBootstrap, type DisplayHostBootstrap,
type EkklesiaHostConfig, type EkklesiaHostConfig,
type HostAccount,
type HostAccountPreferences,
type HostBootstrap, type HostBootstrap,
type HostEventSourceFactory, type HostEventSourceFactory,
type HostEventSourceOptions, type HostEventSourceOptions,
@@ -28,16 +30,20 @@ export {
type HostProtocolErrorCode, type HostProtocolErrorCode,
loadDashboardNavigation, loadDashboardNavigation,
loadDashboardSettings, loadDashboardSettings,
loadHostAccount,
loadHostPrincipal, loadHostPrincipal,
loadModuleDisplay, loadModuleDisplay,
moduleDisplayDescriptorPath, moduleDisplayDescriptorPath,
parseDashboardNavigation, parseDashboardNavigation,
parseDashboardSettings, parseDashboardSettings,
parseDisplayRoute, parseDisplayRoute,
parseHostAccount,
parseHostAccountPreferences,
parseHostModuleDisplay, parseHostModuleDisplay,
parseHostPrincipal, parseHostPrincipal,
resolveApiOrigin, resolveApiOrigin,
resolveApiUrl, resolveApiUrl,
saveHostAccountPreferences,
} from "./host-protocol.js"; } from "./host-protocol.js";
export { export {
type CreateModuleUiContextOptions, type CreateModuleUiContextOptions,
@@ -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: "[email protected]",
name: "Ada Lovelace",
preferences: { appearance: "dark", motion: "reduce" },
} as const;
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
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<string, string>) => 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<string, string>();
const meta = new Map<string, string>();
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<string, string>)
: 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<Response>((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(
<HostApp
appearance={appearance}
config={{ apiBaseUrl, fetch: fetchImplementation }}
history={history}
setDocumentTitle={(title) => 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<HTMLElement>(`[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<void> {
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<HTMLInputElement>("input:checked")?.value ??
undefined
);
}
async function choose(group: "appearance" | "motion", value: string): Promise<void> {
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("[email protected]");
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 [email protected]");
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("[email protected]");
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: "[email protected]",
name: " ",
preferences: { appearance: "system", motion: "system" },
},
});
expect(query("account-name")).toBeNull();
expect(required("account-email").textContent).toBe("[email protected]");
expect(required("host-account-menu").textContent).toContain("[email protected]");
});
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<HTMLAnchorElement>(".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<HTMLInputElement>('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<HTMLElement>(".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();
});
});
@@ -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<string, string> = {}) {
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<string, string>();
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<void> {
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: "[email protected]", name: "Ada Lovelace" }, "AL"],
[{ email: "[email protected]", name: "Ada King Lovelace" }, "AL"],
[{ email: "[email protected]", name: " ada lovelace " }, "AL"],
[{ email: "[email protected]", name: "Cher" }, "C"],
[{ email: "[email protected]", name: "Émile Zola" }, "ÉZ"],
[{ email: "[email protected]", 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: "[email protected]", name: " " })).toBe(
"[email protected]",
);
expect(accountDisplayName({ email: "[email protected]", name: "Ada" })).toBe("Ada");
});
});
+22 -3
View File
@@ -8,9 +8,13 @@ import { dashboardHostAssetUrls } from "../src/dashboard-assets.js";
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
describe("Dashboard host asset locator", () => { 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(Object.isFrozen(dashboardHostAssetUrls)).toBe(true);
expect(dashboardHostAssetUrls).toEqual({ 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, html: new URL("dist/browser/index.html", `file://${packageDirectory}/`).href,
script: new URL( script: new URL(
"dist/browser/assets/ekklesia-dashboard/app.js", "dist/browser/assets/ekklesia-dashboard/app.js",
@@ -43,14 +47,28 @@ describe("Dashboard host asset locator", () => {
expect(html).toContain("color: var(--ekklesia-text-primary);"); 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"); const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
expect(html).toContain('id="ekklesia-host"'); expect(html).toContain('id="ekklesia-host"');
expect(html.match(/<script\b/gu)).toHaveLength(1); expect(html.match(/<script\b/gu)).toHaveLength(2);
expect(html).toContain( expect(html).toContain(
'<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>', '<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
); );
expect(html).toContain('<script src="/assets/ekklesia-dashboard/appearance.js"></script>');
// Every script names a source: there is no inline script for a strict script-src to excuse.
expect(html.match(/<script(?![^>]*\bsrc=")/gu)).toBeNull();
});
it("applies the cached appearance before first paint: a classic script in <head>, 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("<head>"), html.indexOf("</head>"));
const boot = head.indexOf('<script src="/assets/ekklesia-dashboard/appearance.js"></script>');
// 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 () => { 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/app": path.join(');
expect(buildScript).toContain('"assets/ekklesia-dashboard/appearance": path.join(');
}); });
}); });
+30 -2
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
ACCOUNT_SECTIONS,
DASHBOARD_PLATFORM_PAGES, DASHBOARD_PLATFORM_PAGES,
dashboardPath, dashboardPath,
parseDashboardPath, parseDashboardPath,
@@ -37,8 +38,35 @@ describe("parseDashboardPath", () => {
it("never reads a platform page as a module page", () => { it("never reads a platform page as a module page", () => {
expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" }); expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" });
for (const pathname of ["/account", "/account/appearance"]) { expect(parseDashboardPath("/account")).toEqual({ kind: "account" });
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" }); });
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" },
);
} }
}); });
+2
View File
@@ -484,6 +484,8 @@ describe("Ekklesia shell — browser authentication", () => {
"/api/v1/dashboard/navigation", "/api/v1/dashboard/navigation",
// Which Settings destinations the person may see, asked alongside the navigation (ADR 0061). // Which Settings destinations the person may see, asked alongside the navigation (ADR 0061).
"/api/v1/dashboard/settings", "/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. // The host-owned device panel loads with the rest of the shell for an administrator.
"/api/v1/devices", "/api/v1/devices",
]); ]);
+116
View File
@@ -4,11 +4,15 @@ import {
type HostModuleUiDescriptor, type HostModuleUiDescriptor,
HostProtocolError, HostProtocolError,
loadDashboardNavigation, loadDashboardNavigation,
loadHostAccount,
loadHostPrincipal, loadHostPrincipal,
parseDashboardNavigation, parseDashboardNavigation,
parseDashboardSettings, parseDashboardSettings,
parseHostAccount,
parseHostAccountPreferences,
parseHostPrincipal, parseHostPrincipal,
resolveApiOrigin, resolveApiOrigin,
saveHostAccountPreferences,
} from "../src/host-protocol.js"; } from "../src/host-protocol.js";
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js"; import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
@@ -37,6 +41,12 @@ function stubFetch(routes: Readonly<Record<string, () => Response>>) {
return { calls, implementation: implementation as unknown as typeof fetch }; return { calls, implementation: implementation as unknown as typeof fetch };
} }
const accountPayload = {
email: "[email protected]",
name: "Ada Lovelace",
preferences: { appearance: "dark", motion: "reduce" },
};
const inventoryPayload = { const inventoryPayload = {
modules: [ modules: [
{ {
@@ -304,6 +314,7 @@ describe("host protocol — bootstrap", () => {
sections: ["displays"], sections: ["displays"],
}), }),
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }), "/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
"/api/v1/account": () => jsonResponse(accountPayload),
}); });
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation }); const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
@@ -312,7 +323,9 @@ describe("host protocol — bootstrap", () => {
`${apiBaseUrl}/api/v1/principal`, `${apiBaseUrl}/api/v1/principal`,
`${apiBaseUrl}/api/v1/dashboard/navigation`, `${apiBaseUrl}/api/v1/dashboard/navigation`,
`${apiBaseUrl}/api/v1/dashboard/settings`, `${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.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor); expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
expect(bootstrap.settings).toEqual({ 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`]);
});
});
+1 -1
View File
@@ -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 () => { 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 () => { await act(async () => {
root.unmount(); root.unmount();
}); });
+12 -4
View File
@@ -384,12 +384,13 @@ describe("the account area", () => {
expect(trigger.getAttribute("aria-haspopup")).toBe("menu"); expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.textContent).toContain("Your account"); 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(container.textContent).not.toContain("user-1");
expect(query("host-account-menu-list")).toBeNull(); 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(); await render();
const trigger = required("host-account-menu"); 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-expanded")).toBe("true");
expect(trigger.getAttribute("aria-controls")).toBe(menu.id); expect(trigger.getAttribute("aria-controls")).toBe(menu.id);
const items = [...menu.querySelectorAll('[role="menuitem"]')]; 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(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 () => { 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 focus(trigger);
await key(trigger, "ArrowDown"); 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")); expect(document.activeElement).toBe(required("host-logout"));
await key(required("host-logout"), "Escape"); await key(required("host-logout"), "Escape");
+1
View File
@@ -10,6 +10,7 @@ export default defineConfig({
"core/tests/authorization-store.integration.ts", "core/tests/authorization-store.integration.ts",
"core/tests/authorization-bootstrap.integration.ts", "core/tests/authorization-bootstrap.integration.ts",
"core/tests/dashboard-store.integration.ts", "core/tests/dashboard-store.integration.ts",
"core/tests/account-store.integration.ts",
"core/tests/module-lifecycle-store.integration.ts", "core/tests/module-lifecycle-store.integration.ts",
"core/tests/module-configuration-store.integration.ts", "core/tests/module-configuration-store.integration.ts",
"core/tests/module-storage.integration.ts", "core/tests/module-storage.integration.ts",