Stage 1: account area and account-synced Appearance preferences #109
@@ -941,8 +941,7 @@ The Dashboard follows the operating system's **Light or Dark** appearance and **
|
||||
setting, live and without a reload, and so does every module's UI, through the same design tokens
|
||||
([ADR 0060](docs/architecture/decisions/0060-light-dark-system-theme-and-motion.md)). The dark theme is
|
||||
designed rather than inverted, every text and focus pairing is contrast-tested to WCAG AA in both
|
||||
schemes, corners scale from one `radius.scale` token, and an explicit Light or Dark choice is one call
|
||||
away once account preferences exist. Displays stay light.
|
||||
schemes, and corners scale from one `radius.scale` token. Displays stay light.
|
||||
|
||||
**Settings** has its own place in the sidebar
|
||||
([ADR 0061](docs/architecture/decisions/0061-settings-hierarchy-and-module-settings-routes.md)): a
|
||||
@@ -952,6 +951,14 @@ and updated (`/settings/discover/browse`, `/installed`, `/updates`); configuring
|
||||
Settings page. A person sees only what the server says they may administer, and no Settings link at all
|
||||
when that is nothing. The pages are placeholders until each section is built.
|
||||
|
||||
**Your account** is at the bottom of the sidebar, showing your name, email, and initials
|
||||
([ADR 0062](docs/architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
|
||||
`/account` shows who you are signed in as. On `/account/appearance` you choose **System, Light, or
|
||||
Dark** and **reduced motion** (System, Reduce, or Allow). A choice takes effect at once, is saved to
|
||||
your account so it follows you to every device, and is applied before the first paint on your next
|
||||
visit. Managing your own appearance needs no administrative permission. Profile editing and an
|
||||
avatar image are not part of this.
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
|
||||
@@ -1007,7 +1014,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
6 permissions registered
|
||||
7 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
@@ -1017,7 +1024,7 @@ Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
7 migrations · checksums valid
|
||||
9 migrations · checksums valid
|
||||
core:0000_identity_core_tables · f8f2086ef06f
|
||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||
core:0002_authorization_integrity · 634294731180
|
||||
@@ -1025,6 +1032,8 @@ Data definitions
|
||||
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||
core:0005_module_configuration · c8d57f24f84b
|
||||
core:0006_device_identity · 1db97b7e3ac5
|
||||
core:0007_verified_bootstrap_owners · 49013062f3e0
|
||||
core:0008_user_preferences · fa9d4b0bb9d3
|
||||
|
||||
PostgreSQL
|
||||
✓ Database TLS verified
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@ekklesia/design-tokens": "workspace:*",
|
||||
"@ekklesia/module-ui-host": "workspace:*",
|
||||
"@ekklesia/module-ui-sdk": "workspace:*",
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AuthorizationStore,
|
||||
PermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { AccountStore } from "@ekklesia/core/account";
|
||||
import type { DashboardStore } from "@ekklesia/core/dashboard";
|
||||
import type { DeviceStore } from "@ekklesia/core/devices";
|
||||
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
@@ -28,6 +29,7 @@ import type { Context, MiddlewareHandler } from "hono";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
|
||||
import { createAccountRoutes } from "./account-routes.js";
|
||||
import type { DashboardHostAssets } from "./dashboard-host-assets.js";
|
||||
import { createDashboardHostRoutes } from "./dashboard-host-routes.js";
|
||||
import { createDashboardRoutes } from "./dashboard-routes.js";
|
||||
@@ -141,6 +143,10 @@ export interface CreateApiAppOptions {
|
||||
* presentation only — nothing reachable through this store influences discovery, dependency
|
||||
* ordering, module startup, or any authorization decision. */
|
||||
readonly dashboard: DashboardStore;
|
||||
/** Core's persistence for a signed-in person's own account summary and preferences (ADR 0062).
|
||||
* Required like `dashboard`: every installation has accounts. Reachable only through routes that
|
||||
* take the account from the session, never from the request. */
|
||||
readonly account: AccountStore;
|
||||
/**
|
||||
* The built Dashboard's two files, read once by production startup (ADR 0044). Required rather
|
||||
* than optional, unlike `displayHostAssets`: every installation has a Dashboard, and a missing
|
||||
@@ -469,6 +475,20 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||
}),
|
||||
);
|
||||
|
||||
// The signed-in person's own account and preferences (ADR 0062). Self-only by construction: the
|
||||
// account is always the session's, and no request value can name another. See
|
||||
// `./account-routes.js`.
|
||||
app.route(
|
||||
"/",
|
||||
createAccountRoutes({
|
||||
evaluator: options.authorization.evaluator,
|
||||
registry: options.authorization.registry,
|
||||
runtime,
|
||||
store: options.account,
|
||||
trustedOrigins: allowedOrigins,
|
||||
}),
|
||||
);
|
||||
|
||||
// Mounted before module routes and, critically, under its own sibling prefix: `/api/v1/modules/`
|
||||
// belongs entirely to installed modules, so the platform's own lifecycle resource lives beside it
|
||||
// rather than inside it. See `./module-lifecycle-routes.js`.
|
||||
|
||||
@@ -3,7 +3,9 @@ import { readFile } from "node:fs/promises";
|
||||
import { dashboardHostAssetUrls } from "@ekklesia/shell/dashboard-assets";
|
||||
|
||||
/**
|
||||
* The two immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044).
|
||||
* The three immutable, platform-owned files that make up the built Ekklesia Dashboard (ADR 0044):
|
||||
* the page, its application script, and the small pre-paint appearance script the page loads in
|
||||
* `<head>` (ADR 0062).
|
||||
*
|
||||
* Exactly the model `./display-host-assets.js` uses: read once, before the HTTP listener is bound,
|
||||
* from the exact URLs the surface package exports. Request input is never involved in this read, the
|
||||
@@ -16,6 +18,8 @@ export interface DashboardHostAssets {
|
||||
readonly htmlEtag: string;
|
||||
readonly script: string;
|
||||
readonly scriptEtag: string;
|
||||
readonly appearanceScript: string;
|
||||
readonly appearanceScriptEtag: string;
|
||||
}
|
||||
|
||||
/** A startup failure with a fixed, non-secret shape. The originating filesystem exception, package
|
||||
@@ -42,11 +46,21 @@ function strongEtag(text: string): string {
|
||||
* can never be paired with an ETag from another. Empty output is a failed shell build rather than a
|
||||
* valid page that can never render anything.
|
||||
*/
|
||||
export function createDashboardHostAssets(html: string, script: string): DashboardHostAssets {
|
||||
if (html.trim().length === 0 || script.trim().length === 0) {
|
||||
export function createDashboardHostAssets(
|
||||
html: string,
|
||||
script: string,
|
||||
appearanceScript: string,
|
||||
): DashboardHostAssets {
|
||||
if (
|
||||
html.trim().length === 0 ||
|
||||
script.trim().length === 0 ||
|
||||
appearanceScript.trim().length === 0
|
||||
) {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
return Object.freeze({
|
||||
appearanceScript,
|
||||
appearanceScriptEtag: strongEtag(appearanceScript),
|
||||
html,
|
||||
htmlEtag: strongEtag(html),
|
||||
script,
|
||||
@@ -92,7 +106,7 @@ export function renderDashboardPage(html: string, apiOrigin: string): string {
|
||||
return parts.join(`<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
|
||||
* `/` with a 404 or a page that cannot find its API. */
|
||||
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.
|
||||
const html = await readFile(new URL(dashboardHostAssetUrls.html), "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 {
|
||||
throw new DashboardHostAssetError();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { createDashboardHostAssets, type DashboardHostAssets } from "./dashboard
|
||||
*/
|
||||
export const DASHBOARD_HOST_PAGE_PATH = "/";
|
||||
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
|
||||
@@ -56,8 +58,12 @@ export class DashboardHostConfigurationError extends Error {
|
||||
* a composition can never pair bytes with a validator computed from different bytes. */
|
||||
function snapshotAssets(input: DashboardHostAssets): DashboardHostAssets {
|
||||
try {
|
||||
const snapshot = createDashboardHostAssets(input.html, input.script);
|
||||
if (snapshot.htmlEtag !== input.htmlEtag || snapshot.scriptEtag !== input.scriptEtag) {
|
||||
const snapshot = createDashboardHostAssets(input.html, input.script, input.appearanceScript);
|
||||
if (
|
||||
snapshot.htmlEtag !== input.htmlEtag ||
|
||||
snapshot.scriptEtag !== input.scriptEtag ||
|
||||
snapshot.appearanceScriptEtag !== input.appearanceScriptEtag
|
||||
) {
|
||||
throw new DashboardHostConfigurationError();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ModuleDatabaseExecutor, ModuleId } from "@ekklesia/contracts";
|
||||
import { type AccountDrizzleDatabase, createAccountDrizzleView } from "@ekklesia/core/account";
|
||||
import {
|
||||
type AuthorizationDrizzleDatabase,
|
||||
createAuthorizationDrizzleView,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from "@ekklesia/core/modules";
|
||||
|
||||
export interface ApiDatabaseHandle {
|
||||
readonly account: AccountDrizzleDatabase;
|
||||
readonly authorization: AuthorizationDrizzleDatabase;
|
||||
readonly devices: DeviceDrizzleDatabase;
|
||||
readonly dashboard: DashboardDrizzleDatabase;
|
||||
@@ -74,6 +76,7 @@ export function createApiDatabase(
|
||||
const { close, pool } = createValidatedPostgresPool(databaseUrl, options);
|
||||
|
||||
return Object.freeze({
|
||||
account: createAccountDrizzleView(pool),
|
||||
authorization: createAuthorizationDrizzleView(pool),
|
||||
close,
|
||||
devices: createDeviceDrizzleView(pool),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createAuthorizationEvaluator,
|
||||
createAuthorizationStore,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import { createAccountStore } from "@ekklesia/core/account";
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
|
||||
@@ -639,6 +640,8 @@ export async function startApi(
|
||||
},
|
||||
// A typed view over the same single pool as identity, authorization, and the module facade.
|
||||
dashboard: createDashboardStore(databaseHandle.dashboard),
|
||||
// The same pool again: a person's own account summary and preferences (ADR 0062).
|
||||
account: createAccountStore(databaseHandle.account),
|
||||
dashboardHostAssets,
|
||||
// The app is only composed after `startDiscoveredModuleRuntime` resolved, so module startup
|
||||
// has completed whenever it can answer; it stops counting as complete once shutdown begins.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -260,6 +260,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
@@ -1521,6 +1522,162 @@ describe.sequential("live API — exported browser auth shell against PostgreSQL
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live API — a person's own account and preferences (ADR 0062)", () => {
|
||||
let database: { name: string; url: string };
|
||||
let running: RunningApi;
|
||||
let origin: string;
|
||||
let ada: { cookie: string; userId: string };
|
||||
let grace: { cookie: string; userId: string };
|
||||
const password = "correct horse battery staple";
|
||||
|
||||
async function account(cookie?: string): Promise<{ status: number; body: unknown }> {
|
||||
const response = await fetch(`${origin}/api/v1/account`, {
|
||||
headers: { origin: dashboardOrigin, ...(cookie ? { cookie } : {}) },
|
||||
});
|
||||
return { body: await response.json(), status: response.status };
|
||||
}
|
||||
|
||||
async function changePreferences(
|
||||
cookie: string | undefined,
|
||||
body: unknown,
|
||||
options: { readonly origin?: string; readonly query?: string } = {},
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const response = await fetch(`${origin}/api/v1/account/preferences${options.query ?? ""}`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
origin: options.origin ?? dashboardOrigin,
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
method: "PATCH",
|
||||
});
|
||||
return { body: await response.json(), status: response.status };
|
||||
}
|
||||
|
||||
async function person(email: string): Promise<{ cookie: string; userId: string }> {
|
||||
const created = await signUp(origin, email, password);
|
||||
expect(created.response.status).toBe(200);
|
||||
const session = await signIn(origin, email, password);
|
||||
if (!session.cookiePair) {
|
||||
throw harnessError("The account session cookie was not issued.");
|
||||
}
|
||||
return { cookie: session.cookiePair, userId: created.body.user.id };
|
||||
}
|
||||
|
||||
it("starts a freshly migrated API with two ordinary accounts", async () => {
|
||||
database = await createDisposableDatabase("account_prefs");
|
||||
await applyCoreMigrations(database.url);
|
||||
running = await startMigratedApi(database.url);
|
||||
origin = apiOrigin(running);
|
||||
ada = await person("[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", () => {
|
||||
it("refuses to start against an unmigrated database, and creates no tables or migration history", async () => {
|
||||
const database = await createDisposableDatabase("unmigrated");
|
||||
@@ -2630,10 +2787,11 @@ describe.sequential("live API — installed module permission end-to-end", () =>
|
||||
expect(moduleCheck).toMatchObject({ status: "pass" });
|
||||
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
|
||||
expect(permissionsCheck).toMatchObject({
|
||||
// Core's own six (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// Core's own seven (`core.authorization.grants.manage`, `core.modules.ui.read`,
|
||||
// `core.dashboard.navigation.manage`, `core.modules.lifecycle.manage`,
|
||||
// `core.modules.configuration.manage`, `core.devices.manage`) plus the fixture module's one.
|
||||
details: ["7 permissions registered"],
|
||||
// `core.modules.configuration.manage`, `core.devices.manage`, `core.account.self.manage`)
|
||||
// plus the fixture module's one.
|
||||
details: ["8 permissions registered"],
|
||||
status: "pass",
|
||||
});
|
||||
// Doctor and permission composition read only the manifest — the fixture's register/start
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
createApiApp as createApiAppWithModules,
|
||||
} from "../src/app.js";
|
||||
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -111,6 +112,7 @@ function createAuthWiringApp(runtime: EkklesiaIdentityRuntime) {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -130,6 +132,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -366,6 +369,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -391,6 +395,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -418,6 +423,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -447,6 +453,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -476,6 +483,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -502,6 +510,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -530,6 +539,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -555,6 +565,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -582,6 +593,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -616,6 +628,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -651,6 +664,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -691,6 +705,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -713,6 +728,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -737,6 +753,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -760,6 +777,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -781,6 +799,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -809,6 +828,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -840,6 +860,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -874,6 +895,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -899,6 +921,7 @@ describe("Ekklesia API Hono application", () => {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -938,6 +961,7 @@ describe("split Dashboard and API origins (ADR 0045)", () => {
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
moduleRealtime,
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -8,6 +8,7 @@ vi.mock("@ekklesia/shell/dashboard-assets", () => ({
|
||||
dashboardHostAssetUrls: Object.freeze({
|
||||
html: "file:///surface/browser/index.html",
|
||||
script: "file:///surface/browser/assets/ekklesia-dashboard/app.js",
|
||||
appearance: "file:///surface/browser/assets/ekklesia-dashboard/appearance.js",
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -28,19 +29,26 @@ function sha256Etag(text: string): string {
|
||||
describe("Dashboard host asset loading", () => {
|
||||
beforeEach(() => readFileMock.mockReset());
|
||||
|
||||
it("reads only the two declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
it("reads only the three declared file URLs in order and freezes one consistent snapshot", async () => {
|
||||
const html = `<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}<div id="ekklesia-host"></div>`;
|
||||
const script = "export const dashboardHost = true;\n";
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce(script);
|
||||
const appearanceScript = "(() => {})();\n";
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(html)
|
||||
.mockResolvedValueOnce(script)
|
||||
.mockResolvedValueOnce(appearanceScript);
|
||||
|
||||
const assets = await loadDashboardHostAssets(API_ORIGIN);
|
||||
|
||||
expect(readFileMock.mock.calls).toEqual([
|
||||
[new URL("file:///surface/browser/index.html"), "utf8"],
|
||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/app.js"), "utf8"],
|
||||
[new URL("file:///surface/browser/assets/ekklesia-dashboard/appearance.js"), "utf8"],
|
||||
]);
|
||||
const rendered = `<!doctype html><meta content="${API_ORIGIN}" name="ekklesia-api-origin" /><div id="ekklesia-host"></div>`;
|
||||
expect(assets).toEqual({
|
||||
appearanceScript,
|
||||
appearanceScriptEtag: sha256Etag(appearanceScript),
|
||||
html: rendered,
|
||||
htmlEtag: sha256Etag(rendered),
|
||||
script,
|
||||
@@ -83,12 +91,27 @@ describe("Dashboard host asset loading", () => {
|
||||
expect(readFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects an empty page or script as an unavailable shell build", () => {
|
||||
for (const [html, script] of [
|
||||
["", "export {};"],
|
||||
["<html></html>", " \n"],
|
||||
it("fails when the appearance script is missing even though the page and script were read", async () => {
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(`<!doctype html>${DASHBOARD_API_ORIGIN_PLACEHOLDER}`)
|
||||
.mockResolvedValueOnce("export {};\n")
|
||||
.mockRejectedValueOnce(new Error("ENOENT appearance.js"));
|
||||
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
expect(readFileMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("rejects an empty page, script, or appearance script as an unavailable shell build", () => {
|
||||
for (const [html, script, appearance] of [
|
||||
["", "export {};", "(() => {})();"],
|
||||
["<html></html>", " \n", "(() => {})();"],
|
||||
["<html></html>", "export {};", " "],
|
||||
] as const) {
|
||||
expect(() => createDashboardHostAssets(html, script)).toThrow(DashboardHostAssetError);
|
||||
expect(() => createDashboardHostAssets(html, script, appearance)).toThrow(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -99,7 +122,10 @@ describe("Dashboard host asset loading", () => {
|
||||
'<!doctype html><meta content="https://elsewhere.test" name="ekklesia-api-origin" />',
|
||||
]) {
|
||||
readFileMock.mockReset();
|
||||
readFileMock.mockResolvedValueOnce(html).mockResolvedValueOnce("export {};\n");
|
||||
readFileMock
|
||||
.mockResolvedValueOnce(html)
|
||||
.mockResolvedValueOnce("export {};\n")
|
||||
.mockResolvedValueOnce("(() => {})();\n");
|
||||
await expect(loadDashboardHostAssets(API_ORIGIN)).rejects.toBeInstanceOf(
|
||||
DashboardHostAssetError,
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "../src/dashboard-host-assets.js";
|
||||
import {
|
||||
DASHBOARD_HOST_PAGE_PATH,
|
||||
DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH,
|
||||
DASHBOARD_HOST_SCRIPT_PATH,
|
||||
DashboardHostConfigurationError,
|
||||
createDashboardHostRoutes,
|
||||
@@ -51,6 +52,7 @@ import {
|
||||
DISPLAY_HOST_SCRIPT_PATH,
|
||||
DISPLAY_SERVICE_WORKER_PATH,
|
||||
} from "../src/module-display-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import {
|
||||
@@ -73,6 +75,8 @@ function sha256Etag(text: string): string {
|
||||
|
||||
const PAGE_ETAG = sha256Etag(PAGE_HTML);
|
||||
const SCRIPT_ETAG = sha256Etag(SCRIPT);
|
||||
const APPEARANCE_SCRIPT = "(() => { /* pre-paint appearance */ })();\n";
|
||||
const APPEARANCE_SCRIPT_ETAG = sha256Etag(APPEARANCE_SCRIPT);
|
||||
|
||||
function fakeRuntime(): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
@@ -156,8 +160,10 @@ function buildApp(
|
||||
},
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT),
|
||||
dashboardHostAssets:
|
||||
options.assets ?? createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
|
||||
health: readyHealth(),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
@@ -357,7 +363,10 @@ describe("Dashboard deep links (ADR 0057)", () => {
|
||||
for (const modulePages of [["api"], ["settings"], ["display"], ["Not-An-Id"], ["a", "a"]]) {
|
||||
expect(
|
||||
() =>
|
||||
createDashboardHostRoutes(createDashboardHostAssets(PAGE_HTML, SCRIPT), { modulePages }),
|
||||
createDashboardHostRoutes(
|
||||
createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT),
|
||||
{ modulePages },
|
||||
),
|
||||
JSON.stringify(modulePages),
|
||||
).toThrow(DashboardHostConfigurationError);
|
||||
}
|
||||
@@ -457,8 +466,45 @@ describe("GET /assets/ekklesia-dashboard/app.js (the Dashboard script)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /assets/ekklesia-dashboard/appearance.js (the pre-paint appearance script)", () => {
|
||||
it("serves the exact pre-paint script as JavaScript with revalidation and its own strong ETag", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const response = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe(APPEARANCE_SCRIPT);
|
||||
expect(response.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(response.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
// Code only: nothing about any viewer, and nothing that varies by request.
|
||||
expect(response.headers.get("set-cookie")).toBeNull();
|
||||
expect(response.headers.get("vary")).toBeNull();
|
||||
});
|
||||
|
||||
it("answers a matching If-None-Match with a 304, and does not accept the app script's validator", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
const notModified = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
|
||||
headers: { "If-None-Match": APPEARANCE_SCRIPT_ETAG },
|
||||
});
|
||||
expect(notModified.status).toBe(304);
|
||||
expect(notModified.headers.get("etag")).toBe(APPEARANCE_SCRIPT_ETAG);
|
||||
|
||||
const other = await app.request(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH, {
|
||||
headers: { "If-None-Match": SCRIPT_ETAG },
|
||||
});
|
||||
expect(other.status).toBe(200);
|
||||
});
|
||||
|
||||
it("lives beside the app script, in the Dashboard's own asset namespace", () => {
|
||||
expect(DASHBOARD_HOST_APPEARANCE_SCRIPT_PATH).toBe("/assets/ekklesia-dashboard/appearance.js");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dashboard host boundaries", () => {
|
||||
it("serves nothing but its two exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||
it("serves nothing but its exact paths, answering every other spelling with the sanitized 404", async () => {
|
||||
const { app } = buildApp();
|
||||
|
||||
for (const path of [
|
||||
@@ -472,6 +518,10 @@ describe("Dashboard host boundaries", () => {
|
||||
"/assets/ekklesia-dashboard/index.html",
|
||||
"/assets/",
|
||||
"/assets/ekklesia-dashboard/APP.JS",
|
||||
"/assets/ekklesia-dashboard/appearance.js.map",
|
||||
"/assets/ekklesia-dashboard/appearance.js/",
|
||||
"/assets/ekklesia-dashboard/APPEARANCE.JS",
|
||||
"/appearance.js",
|
||||
"/dashboard",
|
||||
"/login",
|
||||
]) {
|
||||
@@ -560,12 +610,21 @@ describe("Dashboard host boundaries", () => {
|
||||
});
|
||||
|
||||
it("refuses at composition a snapshot whose validator does not match its bytes, or empty bytes", () => {
|
||||
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT);
|
||||
const consistent = createDashboardHostAssets(PAGE_HTML, SCRIPT, APPEARANCE_SCRIPT);
|
||||
|
||||
for (const assets of [
|
||||
{ ...consistent, html: `${PAGE_HTML}<!-- tampered -->` },
|
||||
{ ...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);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -140,6 +141,7 @@ async function install(options: InstallOptions = {}): Promise<{ app: Hono }> {
|
||||
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(options.configuration ?? {}),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
DASHBOARD_SETTINGS_PATH,
|
||||
type DashboardSettingsResponse,
|
||||
} from "../src/dashboard-settings-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
@@ -127,6 +128,7 @@ function build(options: Options = {}) {
|
||||
} as unknown as AuthorizationStore;
|
||||
const app = createApiApp({
|
||||
authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store },
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
HEALTH_READY_PATH,
|
||||
READINESS_DATABASE_TIMEOUT_MS,
|
||||
} from "../src/health-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
@@ -91,6 +92,7 @@ function buildApp(
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health,
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||
vi.mock("../src/dashboard-host-assets.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../src/dashboard-host-assets.js")>();
|
||||
loadDashboardHostAssetsMock.mockImplementation(async () =>
|
||||
actual.createDashboardHostAssets("<!doctype html>", "export {};\n"),
|
||||
actual.createDashboardHostAssets("<!doctype html>", "export {};\n", "(() => {})();\n"),
|
||||
);
|
||||
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 { MODULE_CONFIGURATION_PATH } from "../src/module-configuration-routes.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -190,6 +191,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -249,6 +249,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -279,6 +280,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
|
||||
@@ -37,6 +37,7 @@ import { moduleApiBaseUrl } from "../src/module-http-routes.js";
|
||||
import { moduleRealtimeBaseUrl } from "../src/module-realtime-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { moduleDisplayUiArtifactUrl } from "../src/module-ui-routes.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -199,6 +200,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: fakeAuthorizationStore(),
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
mergedVaryHeader,
|
||||
} from "../src/module-http-routes.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { emptyModuleConfiguration } from "./support/in-memory-module-configuration.js";
|
||||
import { emptyModuleLifecycle } from "./support/in-memory-module-lifecycle.js";
|
||||
@@ -125,6 +126,7 @@ function buildModuleApp(options: {
|
||||
moduleLifecycle: emptyModuleLifecycle(),
|
||||
moduleConfiguration: emptyModuleConfiguration(),
|
||||
moduleStorage: emptyModuleStorage(),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -32,6 +32,7 @@ import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js";
|
||||
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
|
||||
import { MODULE_LIFECYCLE_PATH } from "../src/module-lifecycle-routes.js";
|
||||
import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -172,6 +173,7 @@ async function install(options: InstallOptions = {}): Promise<Installation> {
|
||||
registry: composePlatformPermissionRegistry(modules),
|
||||
store: authorizationStore,
|
||||
},
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -33,8 +33,10 @@ import {
|
||||
createModuleUiContext,
|
||||
type EkklesiaHostConfig,
|
||||
moduleUiSourceUrl,
|
||||
saveHostAccountPreferences,
|
||||
} from "@ekklesia/shell";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -216,6 +218,8 @@ interface HarnessOptions {
|
||||
* single module proves very little. */
|
||||
readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>;
|
||||
readonly platformOwner?: boolean;
|
||||
/** People with an account, for the account area's routes (ADR 0062). */
|
||||
readonly people?: Parameters<typeof createInMemoryAccountStore>[0];
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
@@ -289,6 +293,7 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
account: createInMemoryAccountStore(options.people ?? []),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
@@ -302,7 +307,10 @@ async function startHarness(options: HarnessOptions = {}): Promise<Harness> {
|
||||
if (url.pathname.startsWith("/api/v1/modules/")) {
|
||||
moduleApiCalls.push(`${init?.method ?? "GET"} ${url.pathname}${url.search}`);
|
||||
}
|
||||
return app.request(url.href, init);
|
||||
// A browser names the Dashboard's origin on every cross-origin request; the shell never sets it.
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Origin", "https://dashboard.example.test");
|
||||
return app.request(url.href, { ...init, headers });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
return {
|
||||
@@ -738,6 +746,39 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("loads the signed-in person's own account, and a saved preference is what the next load sees (ADR 0062)", async () => {
|
||||
const harness = await startHarness({
|
||||
people: [{ email: "[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 () => {
|
||||
const harness = await startHarness();
|
||||
try {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@ekklesia/core/modules";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -209,6 +210,7 @@ async function install(options: InstallOptions): Promise<Installation> {
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { MODULE_UI_ABI_VERSION, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/mod
|
||||
import { Window } from "happy-dom";
|
||||
import type { Hono } from "hono";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInMemoryAccountStore } from "./support/in-memory-account-store.js";
|
||||
import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js";
|
||||
import { testDashboardHostAssets } from "./support/dashboard-host-assets.js";
|
||||
import { readyHealth } from "./support/ready-health.js";
|
||||
@@ -254,6 +255,7 @@ async function install(
|
||||
},
|
||||
moduleConfiguration: moduleConfigurationOptionFor(modules),
|
||||
moduleStorage: moduleStorageOptionFor(modules),
|
||||
account: createInMemoryAccountStore(),
|
||||
dashboard: createInMemoryDashboardStore(),
|
||||
dashboardHostAssets: testDashboardHostAssets(),
|
||||
health: readyHealth(),
|
||||
|
||||
@@ -9,5 +9,6 @@ export function testDashboardHostAssets(): DashboardHostAssets {
|
||||
return createDashboardHostAssets(
|
||||
'<!doctype html><div id="ekklesia-host"></div><script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||
"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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
"./src/modules/lifecycle-schema.ts",
|
||||
"./src/modules/configuration-schema.ts",
|
||||
"./src/devices/schema.ts",
|
||||
"./src/account/schema.ts",
|
||||
],
|
||||
out: "./drizzle",
|
||||
migrations: {
|
||||
|
||||
@@ -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
@@ -49,6 +49,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0008_user_preferences",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1787543773058,
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1792036365485,
|
||||
"tag": "0008_user_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./account": {
|
||||
"types": "./dist/account/index.d.ts",
|
||||
"import": "./dist/account/index.js"
|
||||
},
|
||||
"./modules": {
|
||||
"types": "./dist/modules/index.d.ts",
|
||||
"import": "./dist/modules/index.js"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./database.js";
|
||||
export * from "./preferences.js";
|
||||
export * from "./schema.js";
|
||||
export * from "./store.js";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -169,6 +169,20 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId
|
||||
*/
|
||||
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
|
||||
|
||||
/**
|
||||
* Authenticated: every signed-in person may read their own name and email and read and change their
|
||||
* own preferences (ADR 0062). Choosing dark mode is not church administration, so it needs no
|
||||
* grant, and a Dashboard where it did would be unusable for its ordinary users. It is a declared
|
||||
* permission rather than a hand-written "is signed in" check, as `core.modules.ui.read` is
|
||||
* (ADR 0026), so the account area is authorized by the same evaluator as everything else.
|
||||
*
|
||||
* **It is self-only by construction.** The routes it guards take the account from the
|
||||
* authenticated session and accept no user id at all, so holding it — whether through the baseline
|
||||
* or a grant — confers no ability to read or change anyone else's account. A device never satisfies
|
||||
* the `authenticated` baseline: a screen in the foyer has no account to read.
|
||||
*/
|
||||
export const CORE_ACCOUNT_SELF_MANAGE: PermissionId = parsePermissionId("core.account.self.manage");
|
||||
|
||||
/**
|
||||
* Core's own declarations, exported in deeply immutable form so a trusted composer (the module
|
||||
* integration layer, `core/src/modules/permissions.ts`) can build one combined registry from
|
||||
@@ -207,6 +221,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob
|
||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||
id: CORE_DEVICES_MANAGE,
|
||||
}),
|
||||
Object.freeze({
|
||||
baseline: "authenticated" as const,
|
||||
description: "Read your own account and read and change your own preferences.",
|
||||
id: CORE_ACCOUNT_SELF_MANAGE,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
@@ -133,7 +134,13 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
expect(CORE_MODULES_CONFIGURATION_MANAGE).not.toBe(CORE_MODULES_LIFECYCLE_MANAGE);
|
||||
});
|
||||
|
||||
it("contains exactly these six permissions and no others", () => {
|
||||
it("registers core.account.self.manage as authenticated: managing your own account needs no grant", () => {
|
||||
const declaration = CORE_PERMISSION_REGISTRY.get(CORE_ACCOUNT_SELF_MANAGE);
|
||||
expect(declaration?.baseline).toBe("authenticated");
|
||||
expect(declaration?.id).toBe("core.account.self.manage");
|
||||
});
|
||||
|
||||
it("contains exactly these seven permissions and no others", () => {
|
||||
expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_MODULES_UI_READ,
|
||||
@@ -141,6 +148,7 @@ describe("CORE_PERMISSION_REGISTRY", () => {
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -178,6 +186,11 @@ describe("CORE_PERMISSION_DECLARATIONS", () => {
|
||||
description: "Approve display device pairing, list paired devices, and revoke a device.",
|
||||
id: CORE_DEVICES_MANAGE,
|
||||
},
|
||||
{
|
||||
baseline: "authenticated",
|
||||
description: "Read your own account and read and change your own preferences.",
|
||||
id: CORE_ACCOUNT_SELF_MANAGE,
|
||||
},
|
||||
]);
|
||||
expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true);
|
||||
for (const declaration of CORE_PERMISSION_DECLARATIONS) {
|
||||
|
||||
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -243,6 +244,8 @@ describe.sequential("live Core authorization store", () => {
|
||||
"ekklesia_core_platform_owner_bootstrap",
|
||||
"ekklesia_core_session",
|
||||
"ekklesia_core_user",
|
||||
// A person's own preferences, one typed column each (ADR 0062).
|
||||
"ekklesia_core_user_preference",
|
||||
"ekklesia_core_verification",
|
||||
]);
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -92,7 +92,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
6 permissions registered
|
||||
7 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
|
||||
@@ -170,12 +170,14 @@ describe("the browser shell reaches none of it", () => {
|
||||
|
||||
it("never puts a reset token in browser storage", async () => {
|
||||
// A reset token is a bearer credential for one account; the shell holds it in React state for
|
||||
// the life of one form and nowhere else. The shell's one use of browser storage is the desktop
|
||||
// sidebar's expanded/collapsed mode (ADR 0058), so that use is pinned exactly: `localStorage` is
|
||||
// named only by the entry point, and only to hand it to the sidebar preference, whose one write
|
||||
// is its own fixed key and a validated mode.
|
||||
// the life of one form and nowhere else. The shell's uses of browser storage are pinned
|
||||
// exactly: the desktop sidebar's expanded/collapsed mode (ADR 0058) and the pre-paint copy of
|
||||
// the person's appearance (ADR 0062). `localStorage` is named only by the entry point, once, to
|
||||
// hand to those two, and by the pre-paint script, once, to read its one key. Each write is its
|
||||
// own fixed key and a validated value.
|
||||
const files = await readTypeScriptFiles(path.join("surfaces", "shell", "src"));
|
||||
const writes: string[] = [];
|
||||
const removals: string[] = [];
|
||||
for (const file of files) {
|
||||
const code = withoutComments(file.text);
|
||||
expect(code, file.path).not.toContain("sessionStorage");
|
||||
@@ -185,18 +187,32 @@ describe("the browser shell reaches none of it", () => {
|
||||
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
|
||||
),
|
||||
);
|
||||
removals.push(
|
||||
...[...code.matchAll(/\.removeItem\(([^)]*)\)/gu)].map(
|
||||
(match) => `${path.relative(repositoryRoot, file.path)}: ${match[1]}`,
|
||||
),
|
||||
);
|
||||
if (file.path.endsWith(path.join("browser", "main.tsx"))) {
|
||||
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
|
||||
expect(code).toMatch(
|
||||
/createLocalSidebarPreference\(\s*\(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
|
||||
/const browserStorage = \(\(\) => \{\s*try \{\s*return window\.localStorage;/u,
|
||||
);
|
||||
expect(code).toContain("createLocalSidebarPreference(browserStorage)");
|
||||
expect(code).toContain("createLocalAppearanceCache(browserStorage)");
|
||||
} else if (file.path.endsWith(path.join("browser", "appearance-boot.ts"))) {
|
||||
expect(code.match(/localStorage/gu), file.path).toHaveLength(1);
|
||||
expect(code).toContain("window.localStorage.getItem(APPEARANCE_CACHE_KEY)");
|
||||
} else {
|
||||
expect(code, file.path).not.toContain("localStorage");
|
||||
}
|
||||
}
|
||||
expect(writes).toEqual([
|
||||
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY, serialized`,
|
||||
`${path.join("surfaces", "shell", "src", "browser", "sidebar-preference.ts")}: SIDEBAR_PREFERENCE_KEY, mode`,
|
||||
]);
|
||||
expect(removals).toEqual([
|
||||
`${path.join("surfaces", "shell", "src", "browser", "appearance-cache.ts")}: APPEARANCE_CACHE_KEY`,
|
||||
]);
|
||||
// The files that ever hold the token touch no storage API at all.
|
||||
for (const file of files.filter((candidate) =>
|
||||
["auth-protocol.ts", "ResetPasswordForm.tsx", "HostApp.tsx"].some((name) =>
|
||||
|
||||
@@ -179,6 +179,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -182,6 +182,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"core:0008_user_preferences",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CORE_AUTHORIZATION_GRANTS_MANAGE,
|
||||
CORE_DASHBOARD_NAVIGATION_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
@@ -75,8 +76,9 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true);
|
||||
expect(registry.has(CORE_MODULES_LIFECYCLE_MANAGE)).toBe(true);
|
||||
expect(registry.has(CORE_MODULES_CONFIGURATION_MANAGE)).toBe(true);
|
||||
// Core's own five declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(8);
|
||||
expect(registry.has(CORE_ACCOUNT_SELF_MANAGE)).toBe(true);
|
||||
// Core's own seven declarations plus one from each module.
|
||||
expect(registry.declarations).toHaveLength(9);
|
||||
});
|
||||
|
||||
it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => {
|
||||
@@ -97,6 +99,7 @@ describe("composePlatformPermissionRegistry", () => {
|
||||
CORE_MODULES_LIFECYCLE_MANAGE,
|
||||
CORE_MODULES_CONFIGURATION_MANAGE,
|
||||
CORE_DEVICES_MANAGE,
|
||||
CORE_ACCOUNT_SELF_MANAGE,
|
||||
"calendar.events.read",
|
||||
"calendar.events.write",
|
||||
"people.directory.read",
|
||||
|
||||
@@ -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.
|
||||
@@ -70,3 +70,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
||||
- [0059: Shared interaction primitives — confirmation, toasts, errors, loading, Back, and no native dialogs](0059-shared-interaction-primitives.md) — Accepted
|
||||
- [0060: Light, Dark, and System appearance; reduced motion; and one radius scale](0060-light-dark-system-theme-and-motion.md) — Accepted
|
||||
- [0061: Settings is one host-owned hierarchy; module lifecycle is Discover, and a module's settings live at `/settings/<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
|
||||
|
||||
@@ -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
|
||||
(`GET /api/v1/dashboard/settings`) as presentation hints, while every route behind them keeps its own
|
||||
enforcement; the Settings section names are reserved module ids
|
||||
([ADR 0061](decisions/0061-settings-hierarchy-and-module-settings-routes.md)). `pnpm boundaries` opens
|
||||
([ADR 0061](decisions/0061-settings-hierarchy-and-module-settings-routes.md)). Personal configuration
|
||||
lives apart from it, in the account area: `/account` shows who is signed in, and `/account/appearance`
|
||||
holds Light, Dark, or System and reduced motion. These preferences belong to the account, stored by
|
||||
Core one typed column per preference and served only to their owner (`GET /api/v1/account`,
|
||||
`PATCH /api/v1/account/preferences`, `core.account.self.manage`), so they follow the person to every
|
||||
device. A small same-origin script in the page's `<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
|
||||
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
|
||||
|
||||
+61
-13
@@ -79,7 +79,8 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
|
||||
| `GET /api/v1/principal` | Ekklesia | Returns the minimal `EkklesiaPrincipal` contract as JSON. `Cache-Control: no-store`. |
|
||||
| `GET /` | Ekklesia | The built Dashboard page (`surfaces/shell`'s `index.html`), with the API's origin written into it. Served from the API's origin, or from the Dashboard's own hostname through the proxy in the split topology. `no-cache` with a strong `ETag`; framing refused. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's one script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /assets/ekklesia-dashboard/app.js` | Ekklesia | The Dashboard's application script. `no-cache` with a strong `ETag`; its source map is never served. See [Dashboard host](#dashboard-host). |
|
||||
| `GET /assets/ekklesia-dashboard/appearance.js` | Ekklesia | The Dashboard's small pre-paint appearance script, loaded in `<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/ready` | Ekklesia | Unauthenticated readiness: fixed `200 {"status":"ready"}` only when a bounded database ping succeeds and module startup completed, otherwise `503 {"code":"NOT_READY"}`. `Cache-Control: no-store`; GET only. See [Health](#health). |
|
||||
| `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
|
||||
@@ -87,6 +88,8 @@ No real values are shown here — configure per environment and never commit the
|
||||
| `GET /api/v1/module-realtime/<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/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). |
|
||||
| `GET /api/v1/account` | Ekklesia | The signed-in person's own name, email, and preferences. Requires `core.account.self.manage` (baseline `authenticated`). Takes no user id. See [Account](#account). |
|
||||
| `PATCH /api/v1/account/preferences` | Ekklesia | Change some of the signed-in person's own preferences. Requires `core.account.self.manage` and a trusted `Origin`. See [Account](#account). |
|
||||
| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). |
|
||||
| `GET /api/v1/module-ui/<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). |
|
||||
@@ -147,14 +150,16 @@ same-origin topology it does so from the same origin as `/api`: `EKKLESIA_DASHBO
|
||||
unset, the session cookie stays host-only, and no second static server or proxy file root is
|
||||
needed. In the split topology
|
||||
([ADR 0045](../architecture/decisions/0045-split-dashboard-and-api-origins.md)) the proxy forwards
|
||||
the Dashboard's own hostname to the same two routes; the API does not inspect `Host`. It works
|
||||
the Dashboard's own hostname to the same routes; the API does not inspect `Host`. It works
|
||||
exactly like the display host:
|
||||
|
||||
- **A closed set of files, read once at startup.** `@ekklesia/shell/dashboard-assets` exports the
|
||||
exact URLs of two built files, `dist/browser/index.html` and
|
||||
`dist/browser/assets/ekklesia-dashboard/app.js`. `startApi()` reads both before importing any
|
||||
module code or binding the listener, and keeps one frozen snapshot of their text with a SHA-256
|
||||
strong `ETag` for each.
|
||||
exact URLs of three built files: `dist/browser/index.html`,
|
||||
`dist/browser/assets/ekklesia-dashboard/app.js`, and the pre-paint
|
||||
`dist/browser/assets/ekklesia-dashboard/appearance.js`
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
|
||||
`startApi()` reads all three before importing any module code or binding the listener, and keeps
|
||||
one frozen snapshot of their text with a SHA-256 strong `ETag` for each.
|
||||
- **The API's origin is written in at startup.** The built page carries one empty
|
||||
`<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
|
||||
@@ -166,33 +171,35 @@ exactly like the display host:
|
||||
`DashboardHostAssetError` (`DASHBOARD_HOST_ASSET_UNAVAILABLE`), after closing the resources
|
||||
already opened. It never carries a path or the filesystem error. Build the shell first;
|
||||
`pnpm api:start` already runs the full build.
|
||||
- **Two exact routes, never a directory.** `GET /` answers the page and
|
||||
`GET /assets/ekklesia-dashboard/app.js` answers the script. No request value selects a file, and
|
||||
- **Exact routes, never a directory.** `GET /` (and the Dashboard's own pages, ADR 0057) answers
|
||||
the page, `GET /assets/ekklesia-dashboard/app.js` the application script, and
|
||||
`GET /assets/ekklesia-dashboard/appearance.js` the pre-paint script. No request value selects a
|
||||
file, and
|
||||
nothing touches the filesystem while answering. Every other path, including `/index.html`,
|
||||
`/app.js`, the script's `.map`, and any traversal spelling, falls through to the ordinary
|
||||
`404 {"code":"NOT_FOUND"}`. There is no catch-all HTML fallback: the Dashboard has no client
|
||||
router, and emailed links land on `/` with a query (see
|
||||
[The four anonymous screens](#the-four-anonymous-screens)). Methods other than `GET` (and Hono's
|
||||
implicit `HEAD`) are also the ordinary `404`.
|
||||
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the two
|
||||
files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
|
||||
- **No source maps.** The build emits `app.js.map` beside the script, but it is not one of the
|
||||
served files, so it is never served, matching module UI publication (ADR 0025). Browser devtools receive
|
||||
a `404` for it.
|
||||
|
||||
Neither route resolves a principal or reads a cookie, and neither is under `/api/`, so the API's
|
||||
CORS policy does not apply. The bytes grant no authority: the page renders sign-in until
|
||||
`/api/v1/principal` says otherwise.
|
||||
|
||||
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` |
|
||||
| Header | `GET /` | `GET /assets/ekklesia-dashboard/app.js` and `appearance.js` |
|
||||
| --- | --- | --- |
|
||||
| `Content-Type` | `text/html; charset=utf-8` | `text/javascript; charset=utf-8` |
|
||||
| `Cache-Control` | `no-cache` | `no-cache` |
|
||||
| `ETag` | strong SHA-256 of the page | strong SHA-256 of the script |
|
||||
| `ETag` | strong SHA-256 of the page | strong SHA-256 of each script |
|
||||
| `X-Content-Type-Options` | `nosniff` | `nosniff` |
|
||||
| `X-Frame-Options` | `DENY` | — |
|
||||
| `Content-Security-Policy` | `frame-ancestors 'none'` | — |
|
||||
| `Referrer-Policy` | `no-referrer` | — |
|
||||
|
||||
**Caching.** The shell emits fixed, unhashed file names, so both files are revalidated on every
|
||||
**Caching.** The shell emits fixed, unhashed file names, so every file is revalidated on every
|
||||
load, exactly like module UI artifacts and the display host's script. A matching `If-None-Match`
|
||||
(strong, weak `W/`, a list, or `*`) answers an empty `304` carrying the same `ETag` and
|
||||
`Cache-Control`. The page's `304` also carries its security headers, so a revalidated cache entry
|
||||
@@ -1218,6 +1225,47 @@ platform-global; Ekklesia has no organization or tenant model to scope it to yet
|
||||
|
||||
Responses are per-principal, so `Cache-Control: no-store` and `Vary: Origin, Cookie`.
|
||||
|
||||
## Account
|
||||
|
||||
The signed-in person's own account
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)): who
|
||||
they are signed in as, and their personal preferences, which follow them to every device.
|
||||
|
||||
| Route | Body | `200` answer |
|
||||
| --- | --- | --- |
|
||||
| `GET /api/v1/account` | none | `{"name":"Ada Lovelace","email":"[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
|
||||
|
||||
```text
|
||||
|
||||
@@ -112,6 +112,8 @@ list:
|
||||
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
|
||||
| `core.modules.lifecycle.manage` | `restricted` | Read and change which installed modules are enabled. |
|
||||
| `core.modules.configuration.manage` | `restricted` | Read and change an installed module's own configuration values. |
|
||||
| `core.devices.manage` | `restricted` | Approve display device pairing, list paired devices, and revoke a device. |
|
||||
| `core.account.self.manage` | `authenticated` | Read your own account and read and change your own preferences. |
|
||||
|
||||
Every Core permission is a **platform** permission and can never be declared, gated, or influenced by
|
||||
a module — `core.*` is a reserved namespace (see below). `core.modules.lifecycle.manage` gates both
|
||||
@@ -122,6 +124,13 @@ milder version of the write, because the lifecycle view is the only place a *dis
|
||||
`core.modules.ui.read` deliberately covers only what is mountable. Holding it confers no authority
|
||||
inside any module.
|
||||
|
||||
`core.account.self.manage` is the one Core permission every signed-in person holds by baseline
|
||||
besides `core.modules.ui.read`: choosing your own appearance is not church administration
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)). It is
|
||||
**self-only by construction** rather than by a check: the account routes take the account from the
|
||||
session and accept no user id at all, so the permission, however it is held, confers nothing over
|
||||
anyone else's account. A device never satisfies its `authenticated` baseline.
|
||||
|
||||
`core.modules.configuration.manage` is deliberately **separate from** the lifecycle permission
|
||||
([ADR 0029](../architecture/decisions/0029-module-configuration-and-secrets.md)): deciding whether a
|
||||
module runs and deciding how it behaves are different authorities, and someone trusted to tune a
|
||||
@@ -235,7 +244,7 @@ generic explicit-grant HTTP API, enforcement middleware, and now its own mounted
|
||||
`pnpm doctor`'s `modules.permissions` check composes the platform permission registry from the
|
||||
same already-discovered module list `modules.discovery`/`modules.dependencies` already produced —
|
||||
it never re-discovers modules itself. A successful composition reports a pass with a safe
|
||||
declaration count (for example `5 permissions registered` with zero installed modules, since Core's
|
||||
declaration count (for example `7 permissions registered` with zero installed modules, since Core's
|
||||
own declarations are always present); zero installed modules still composes successfully. If module
|
||||
discovery itself failed, the check is skipped, matching the existing schema/migration checks'
|
||||
pattern; if discovery succeeded but composition failed, schema and migration checks still run
|
||||
|
||||
@@ -136,7 +136,7 @@ Modules
|
||||
✓ Dependency graph valid
|
||||
Dependency order is empty.
|
||||
✓ Permission registry composed
|
||||
6 permissions registered
|
||||
7 permissions registered
|
||||
○ Module lifecycle skipped
|
||||
DATABASE_URL is not configured.
|
||||
○ Module configuration skipped
|
||||
@@ -146,7 +146,7 @@ Data definitions
|
||||
✓ Schemas validated
|
||||
0 modules · 0 tables
|
||||
✓ Migrations validated
|
||||
7 migrations · checksums valid
|
||||
9 migrations · checksums valid
|
||||
core:0000_identity_core_tables · f8f2086ef06f
|
||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||
core:0002_authorization_integrity · 634294731180
|
||||
@@ -154,6 +154,8 @@ Data definitions
|
||||
core:0004_module_lifecycle_state · c8b5d8b3d48b
|
||||
core:0005_module_configuration · c8d57f24f84b
|
||||
core:0006_device_identity · 1db97b7e3ac5
|
||||
core:0007_verified_bootstrap_owners · 49013062f3e0
|
||||
core:0008_user_preferences · fa9d4b0bb9d3
|
||||
|
||||
PostgreSQL
|
||||
✓ Database TLS verified
|
||||
|
||||
@@ -425,6 +425,28 @@ enforced by `requirePermission` against the composed platform permission registr
|
||||
enforcement neither knows nor cares what a browser decided. Hiding a control is a usability
|
||||
improvement, never an access control. Treat any claim that a UI check protects anything as a bug.
|
||||
|
||||
### A person's account is theirs alone, and the browser's copy of it is not authority
|
||||
|
||||
The account area
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md))
|
||||
returns a signed-in person's name, email, and preferences, and lets them change their preferences. It
|
||||
is **self-only by construction**: `GET /api/v1/account` and `PATCH /api/v1/account/preferences` take
|
||||
the account from the session and accept no user id anywhere, so no request can name another person.
|
||||
Both routes require `core.account.self.manage` (baseline `authenticated`), which no device satisfies.
|
||||
The mutation requires an exact trusted `Origin` and a bounded body, like every Core mutation.
|
||||
|
||||
- **Nothing more leaves the server.** The account's image URL, verification state, timestamps, and
|
||||
user id are never returned.
|
||||
- **No avatar image is loaded.** The account's `image` is an arbitrary, unvetted URL, and fetching it
|
||||
would disclose every Dashboard visit to whatever host it names. Initials are drawn instead.
|
||||
- **Nothing about the account is in the HTML.** The Dashboard page is still one snapshot for everyone.
|
||||
The pre-paint `appearance.js` is a same-origin external script, so the page still has no inline
|
||||
script and a strict `script-src` needs no exception.
|
||||
- **The browser keeps two words.** It stores the appearance and motion words under
|
||||
`ekklesia.dashboard.appearance` for the next first paint. That copy holds nothing about the account,
|
||||
is validated strictly before use, is replaced by the account's answer on every load, and is cleared
|
||||
on sign-out. Tampering with it changes nothing but this browser's colours until the account answers.
|
||||
|
||||
## What this platform must not claim
|
||||
|
||||
Ekklesia today has **none** of the following, and no document, README, or marketing surface may
|
||||
|
||||
@@ -404,9 +404,36 @@ no runtime assertion can make: that the identity feature names no SMTP detail, t
|
||||
schema gained a token column and no identity code hashes or compares a token, that nothing
|
||||
interpolates a token, link, address, or body into a log line, that a device has no email, password,
|
||||
or verification concept, that no browser source imports Better Auth or Core's mail, and that the
|
||||
shell's only use of browser storage is the sidebar's expanded/collapsed mode — `localStorage` named
|
||||
only by the entry point, one write of one fixed key, and no storage API in any file that holds the
|
||||
reset token ([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)).
|
||||
shell's uses of browser storage are exactly two: the sidebar's expanded/collapsed mode
|
||||
([ADR 0058](../architecture/decisions/0058-dashboard-shell-navigation-and-lucide-icons.md)) and the
|
||||
pre-paint appearance cache
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)).
|
||||
`localStorage` is named once by the entry point and once, read-only, by the pre-paint script. There is
|
||||
one write of each fixed key, one removal of the appearance key, and no storage API in any file that
|
||||
holds the reset token.
|
||||
|
||||
The account area
|
||||
([ADR 0062](../architecture/decisions/0062-account-area-and-synced-appearance-preferences.md)) is
|
||||
proven at each layer:
|
||||
|
||||
- `core/tests/account-preferences.test.ts` covers the closed preference model, its strict parser, the
|
||||
table's typed columns, and that the migration's `CHECK` lists equal the model.
|
||||
- `core/tests/account-store.integration.ts` uses real PostgreSQL. It proves defaults without writing a
|
||||
row, saving each value, partial updates, isolation between two people, persistence across a new
|
||||
connection, the database refusing an out-of-model value, and the cascade on delete.
|
||||
- `apps/api/tests/account-routes.test.ts` covers the routes. Anonymous and device callers are refused,
|
||||
only the session's account is ever read or written whatever the request names, there is no route
|
||||
that addresses another person, and validation, `Origin`, body limit, failures, and CORS preflight
|
||||
all behave as specified. It also holds Core's value sets equal to the design tokens'.
|
||||
- `apps/api/tests/api-http.integration.ts` runs two real accounts over real HTTP sessions, with the
|
||||
same isolation proven end to end.
|
||||
- `surfaces/shell/tests/appearance-preference.test.ts` covers the cache, the pre-paint script (run in
|
||||
the DOM), and the one handle over the ADR 0060 controller.
|
||||
- `surfaces/shell/tests/account-appearance.test.tsx` covers the account control's identity, both
|
||||
pages, live switching, saving, reverting on failure, reconciling with the account, cross-tab
|
||||
following, sign-out forgetting, and the phone drawer.
|
||||
- The deploy smoke's real Chromium checks Light and Dark rendering, reload persistence, an emulated OS
|
||||
change, reduced motion, a module's Shadow DOM following the theme, and phone width.
|
||||
|
||||
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
|
||||
injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
|
||||
|
||||
Generated
+3
@@ -55,6 +55,9 @@ importers:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.5.3
|
||||
version: 2.5.3
|
||||
'@ekklesia/design-tokens':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/design-tokens
|
||||
'@ekklesia/module-ui-host':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/module-ui-host
|
||||
|
||||
@@ -102,10 +102,27 @@ const send = (method, params) => cdp.send(method, params, sessionId);
|
||||
* `loadingFailed` here, never a response. */
|
||||
const responses = [];
|
||||
const failed = [];
|
||||
/** Requests the page has started and not finished, by id: what a timed-out wait was stuck behind.
|
||||
* Only paths are ever printed, never a query, header, or body. */
|
||||
const inflight = new Map();
|
||||
/** Every request's URL by id, so a failure can be named after the request it ended. */
|
||||
const inflightUrls = new Map();
|
||||
cdp.on((message) => {
|
||||
if (message.sessionId !== sessionId) {
|
||||
return;
|
||||
}
|
||||
if (message.method === "Network.requestWillBeSent") {
|
||||
inflightUrls.set(message.params.requestId, message.params.request.url);
|
||||
inflight.set(message.params.requestId, {
|
||||
type: message.params.type,
|
||||
url: message.params.request.url,
|
||||
});
|
||||
} else if (
|
||||
message.method === "Network.loadingFinished" ||
|
||||
message.method === "Network.loadingFailed"
|
||||
) {
|
||||
inflight.delete(message.params.requestId);
|
||||
}
|
||||
if (message.method === "Network.responseReceived") {
|
||||
const { response, type } = message.params;
|
||||
responses.push({ status: response.status, type, url: response.url });
|
||||
@@ -145,6 +162,37 @@ async function waitFor(expression, description) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
console.log(`FAIL timed out waiting for ${description}`);
|
||||
const page = await evaluate(
|
||||
`({ path: location.pathname, ready: document.readyState, shell: Boolean(document.querySelector('[data-testid="host-shell"]')), loading: Boolean(document.querySelector('[data-testid="host-loading"]')), error: Boolean(document.querySelector('[data-testid="host-error"], [data-testid="module-error"]')) })`,
|
||||
).catch(() => "unavailable");
|
||||
const pending = [...inflight.values()].map((request) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
return `${request.type} ${url.origin === apiOrigin ? "api" : url.origin === dashboardOrigin ? "dashboard" : "other"}${url.pathname}`;
|
||||
} catch {
|
||||
return request.type;
|
||||
}
|
||||
});
|
||||
const shortPath = (value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return `${url.origin === apiOrigin ? "api" : url.origin === dashboardOrigin ? "dashboard" : "other"}${url.pathname}`;
|
||||
} catch {
|
||||
return "unparseable";
|
||||
}
|
||||
};
|
||||
// The last few refused or failed requests: which one broke the page, and how.
|
||||
const refusedRecently = responses
|
||||
.filter((response) => response.status >= 400)
|
||||
.slice(-6)
|
||||
.map((response) => `${response.status} ${shortPath(response.url)}`);
|
||||
const failedRecently = failed
|
||||
.slice(-6)
|
||||
.map(
|
||||
(failure) =>
|
||||
`${failure.errorText}${failure.corsErrorStatus ? ` cors:${failure.corsErrorStatus.corsError}` : ""} ${shortPath(inflightUrls.get(failure.requestId) ?? "")}`,
|
||||
);
|
||||
console.log(`info ${JSON.stringify({ failedRecently, page, pending, refusedRecently })}`);
|
||||
failures += 1;
|
||||
return false;
|
||||
}
|
||||
@@ -421,6 +469,208 @@ check(
|
||||
"leaving Settings returns to the normal Dashboard navigation",
|
||||
);
|
||||
|
||||
// --- Account and Appearance (ADR 0062): identity, live switching, persistence, first paint ------
|
||||
const emulate = (colorScheme, reducedMotion) =>
|
||||
send("Emulation.setEmulatedMedia", {
|
||||
features: [
|
||||
{ name: "prefers-color-scheme", value: colorScheme },
|
||||
{ name: "prefers-reduced-motion", value: reducedMotion },
|
||||
],
|
||||
});
|
||||
const rootScheme = "getComputedStyle(document.documentElement).colorScheme";
|
||||
const rootToken = (name) =>
|
||||
`getComputedStyle(document.documentElement).getPropertyValue("--ekklesia-${name}").trim()`;
|
||||
const option = (group, value) =>
|
||||
`document.querySelector('[data-testid="appearance-${group}-${value}"] input')`;
|
||||
const savedPreferences = () =>
|
||||
responses.filter(
|
||||
(response) =>
|
||||
response.url === `${apiOrigin}/api/v1/account/preferences` && response.status === 200,
|
||||
).length;
|
||||
/**
|
||||
* Chooses one option on the Appearance page and waits until the account has saved it. An option
|
||||
* already chosen needs no save: a radio that is already checked does not change.
|
||||
*/
|
||||
async function choose(group, value) {
|
||||
if (await evaluate(`Boolean(${option(group, value)}?.checked)`)) {
|
||||
return true;
|
||||
}
|
||||
const before = savedPreferences();
|
||||
await evaluate(`${option(group, value)}.click()`);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (savedPreferences() <= before && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return (
|
||||
savedPreferences() > before &&
|
||||
(await waitFor(
|
||||
`${option(group, value)}?.checked && ${selector("appearance-status")}?.textContent.includes("Saved")`,
|
||||
`${group} ${value} to be saved`,
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
await emulate("light", "no-preference");
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("host-account-menu"), "the account control");
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("host-account-menu")}.textContent.includes(${JSON.stringify(required("SMOKE_OWNER_EMAIL"))}) && ${selector("host-account-menu")}.querySelector('[data-testid="account-avatar"]')`,
|
||||
"the account control to name the signed-in person",
|
||||
),
|
||||
"the account control shows who is signed in, with their initials",
|
||||
);
|
||||
await evaluate(`${selector("host-account-menu")}.click()`);
|
||||
await waitFor(selector("host-appearance-link"), "Appearance in the account menu");
|
||||
await evaluate(`${selector("host-appearance-link")}.click()`);
|
||||
check(
|
||||
await waitFor(
|
||||
`location.pathname === "/account/appearance" && ${selector("account-appearance")}`,
|
||||
"the Appearance page",
|
||||
),
|
||||
"Appearance opens from the account menu at /account/appearance",
|
||||
);
|
||||
// Start from System for both, whatever an earlier pass left on this account.
|
||||
check(
|
||||
(await choose("appearance", "system")) && (await choose("motion", "system")),
|
||||
"System appearance and System motion save to the account",
|
||||
);
|
||||
const lightBackground = await evaluate(`getComputedStyle(document.body).backgroundColor`);
|
||||
await emulate("dark", "no-preference");
|
||||
check(
|
||||
await waitFor(`${rootScheme} === "dark"`, "System to follow a dark operating system"),
|
||||
"with System chosen, an operating system switching to dark restyles the page live",
|
||||
);
|
||||
await emulate("light", "no-preference");
|
||||
check(
|
||||
await waitFor(`${rootScheme} === "light"`, "System to follow a light operating system"),
|
||||
"and switching back to light restyles it again, with no reload",
|
||||
);
|
||||
check(await choose("appearance", "dark"), "choosing Dark saves it to the account");
|
||||
check(
|
||||
await waitFor(
|
||||
`${rootScheme} === "dark" && getComputedStyle(document.body).backgroundColor !== ${JSON.stringify(lightBackground)}`,
|
||||
"Dark to take effect under a light operating system",
|
||||
),
|
||||
"Dark takes effect at once, overriding a light operating system",
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/account/appearance`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("account-appearance")} && ${option("appearance", "dark")}?.checked && ${rootScheme} === "dark" && localStorage.getItem("ekklesia.dashboard.appearance") === "dark system"`,
|
||||
"Dark after a reload",
|
||||
),
|
||||
"a reload keeps Dark: the account holds it, and this browser cached two words for first paint",
|
||||
);
|
||||
// First paint without the application: only the pre-paint script runs, and it already applies Dark.
|
||||
await send("Network.setBlockedURLs", { urls: ["*/assets/ekklesia-dashboard/app.js"] });
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
check(
|
||||
await waitFor(
|
||||
`document.readyState === "complete" && !${selector("host-shell")} && document.documentElement.getAttribute("data-ekklesia-appearance") === "dark" && document.querySelector('meta[name="color-scheme"]').content === "dark"`,
|
||||
"the pre-paint script alone to apply Dark",
|
||||
),
|
||||
"before the application script runs at all, the pre-paint script has applied Dark",
|
||||
);
|
||||
await send("Network.setBlockedURLs", { urls: [] });
|
||||
if (openedModuleId) {
|
||||
const moduleScheme = `(() => {
|
||||
const host = document.querySelector("[data-ekklesia-module-ui]");
|
||||
const inner = host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]");
|
||||
if (!inner) return null;
|
||||
const style = getComputedStyle(inner);
|
||||
return { page: style.getPropertyValue("--ekklesia-background-page").trim(), scheme: style.colorScheme };
|
||||
})()`;
|
||||
await navigate(`${dashboardOrigin}/${openedModuleId}`);
|
||||
await waitFor(
|
||||
`${selector("module-container")}?.childElementCount > 0 && (${moduleScheme})?.scheme === "dark"`,
|
||||
"the module's isolated root in Dark",
|
||||
);
|
||||
const darkModule = await evaluate(moduleScheme);
|
||||
check(
|
||||
darkModule?.scheme === "dark" &&
|
||||
darkModule.page === (await evaluate(rootToken("background-page"))),
|
||||
`the ${openedModuleId} module's Shadow DOM root follows Dark through the tokens`,
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/account/appearance`);
|
||||
await waitFor(selector("account-appearance"), "the Appearance page");
|
||||
await emulate("dark", "no-preference");
|
||||
check(await choose("appearance", "light"), "choosing Light saves it to the account");
|
||||
check(
|
||||
await waitFor(`${rootScheme} === "light"`, "Light under a dark operating system"),
|
||||
"Light takes effect at once, overriding a dark operating system",
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/${openedModuleId}`);
|
||||
await waitFor(
|
||||
`${selector("module-container")}?.childElementCount > 0 && (${moduleScheme})?.scheme === "light"`,
|
||||
"the module's isolated root in Light",
|
||||
);
|
||||
const lightModule = await evaluate(moduleScheme);
|
||||
check(
|
||||
lightModule?.scheme === "light" && lightModule.page !== darkModule?.page,
|
||||
`the ${openedModuleId} module's Shadow DOM root follows Light too`,
|
||||
);
|
||||
await navigate(`${dashboardOrigin}/account/appearance`);
|
||||
await waitFor(selector("account-appearance"), "the Appearance page");
|
||||
}
|
||||
// Reduced motion: System follows the operating system; Allow and Reduce override it.
|
||||
const shortDuration = rootToken("motion-duration-short");
|
||||
await emulate("light", "reduce");
|
||||
check(
|
||||
await waitFor(`${shortDuration} === "0ms"`, "System motion to follow a reduce request"),
|
||||
"with System motion, an operating system asking for less motion zeroes durations",
|
||||
);
|
||||
check(await choose("motion", "allow"), "choosing Allow saves it to the account");
|
||||
check(
|
||||
await waitFor(`${shortDuration} !== "0ms"`, "Allow under a reduce request"),
|
||||
"Allow keeps the platform's subtle motion even when the system asks for less",
|
||||
);
|
||||
await emulate("light", "no-preference");
|
||||
check(await choose("motion", "reduce"), "choosing Reduce saves it to the account");
|
||||
check(
|
||||
await waitFor(`${shortDuration} === "0ms"`, "Reduce with no system request"),
|
||||
"Reduce zeroes motion even when the system asks for none",
|
||||
);
|
||||
if (displayPath !== null) {
|
||||
// The display host is a lobby screen, not a person: it stays Light whatever the OS or the account.
|
||||
check(await choose("appearance", "dark"), "Dark is chosen again before visiting a display");
|
||||
await emulate("dark", "no-preference");
|
||||
await navigate(`${apiOrigin}${displayPath}`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("display-module-container")}?.childElementCount > 0 && ${rootScheme} === "light"`,
|
||||
"the display to stay Light",
|
||||
),
|
||||
"the display page stays Light under a dark operating system and a Dark account choice",
|
||||
);
|
||||
await emulate("light", "no-preference");
|
||||
}
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 2,
|
||||
height: 844,
|
||||
mobile: true,
|
||||
width: 390,
|
||||
});
|
||||
await navigate(`${dashboardOrigin}/account/appearance`);
|
||||
check(
|
||||
await waitFor(
|
||||
`${selector("account-appearance")} && document.documentElement.scrollWidth <= window.innerWidth && [...document.querySelectorAll(".ek-shell-choice")].length === 6 && [...document.querySelectorAll(".ek-shell-choice")].every((element) => element.getBoundingClientRect().height >= 44)`,
|
||||
"the Appearance page at phone width",
|
||||
),
|
||||
"at phone width the Appearance page fits without sideways scrolling, every option a 44px+ target",
|
||||
);
|
||||
check(
|
||||
(await choose("appearance", "system")) && (await choose("motion", "system")),
|
||||
"the account is put back to System for both",
|
||||
);
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
deviceScaleFactor: 1,
|
||||
height: 1080,
|
||||
mobile: false,
|
||||
width: 1920,
|
||||
});
|
||||
await send("Emulation.setEmulatedMedia", { features: [] });
|
||||
|
||||
// --- Sign out on the Dashboard, from the account menu at the bottom of the sidebar ---------------
|
||||
await navigate(`${dashboardOrigin}/`);
|
||||
await waitFor(selector("host-account-menu"), "the account control");
|
||||
@@ -435,6 +685,12 @@ const principal = await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/pr
|
||||
credentials: "include",
|
||||
}).then((response) => response.json()).then((body) => body.kind)`);
|
||||
check(principal === "anonymous", "after sign-out, the API answers anonymous to the same browser");
|
||||
check(
|
||||
await evaluate(
|
||||
`localStorage.getItem("ekklesia.dashboard.appearance") === null && document.documentElement.getAttribute("data-ekklesia-appearance") === "system"`,
|
||||
),
|
||||
"sign-out forgets this browser's cached appearance, and the page follows the system",
|
||||
);
|
||||
|
||||
await cdp.send("Target.closeTarget", { targetId });
|
||||
cdp.close();
|
||||
|
||||
@@ -73,8 +73,9 @@ async function edgeChecks() {
|
||||
check(
|
||||
page.headers.get("content-type") === "text/html; charset=utf-8" &&
|
||||
html.includes('id="ekklesia-host"') &&
|
||||
html.includes('src="/assets/ekklesia-dashboard/app.js"'),
|
||||
"GET / is the Dashboard HTML",
|
||||
html.includes('src="/assets/ekklesia-dashboard/app.js"') &&
|
||||
html.includes('<script src="/assets/ekklesia-dashboard/appearance.js"></script>'),
|
||||
"GET / is the Dashboard HTML, with its pre-paint appearance script (ADR 0062)",
|
||||
);
|
||||
check(
|
||||
page.headers.get("x-frame-options") === "DENY" &&
|
||||
@@ -90,7 +91,12 @@ async function edgeChecks() {
|
||||
|
||||
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module
|
||||
// is installed yet at this point; a module's own address is reloaded in a real browser later.
|
||||
for (const deepLink of ["/settings", "/settings/displays/add", "/account"]) {
|
||||
for (const deepLink of [
|
||||
"/settings",
|
||||
"/settings/displays/add",
|
||||
"/account",
|
||||
"/account/appearance",
|
||||
]) {
|
||||
const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
|
||||
check(
|
||||
deepPage.status === 200 &&
|
||||
@@ -113,6 +119,17 @@ async function edgeChecks() {
|
||||
await script.arrayBuffer();
|
||||
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
|
||||
|
||||
const appearanceScript = await fetch(
|
||||
`${dashboardOrigin}/assets/ekklesia-dashboard/appearance.js`,
|
||||
);
|
||||
const appearanceSource = await appearanceScript.text();
|
||||
check(
|
||||
appearanceScript.status === 200 &&
|
||||
appearanceScript.headers.get("content-type") === "text/javascript; charset=utf-8" &&
|
||||
appearanceSource.includes("ekklesia.dashboard.appearance"),
|
||||
"GET /assets/ekklesia-dashboard/appearance.js is the pre-paint script",
|
||||
);
|
||||
|
||||
const outside = await fetch(`${dashboardOrigin}/favicon.ico`);
|
||||
check(
|
||||
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
|
||||
@@ -181,6 +198,10 @@ async function splitRoutingChecks() {
|
||||
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
|
||||
"the API hostname refuses the Dashboard's script",
|
||||
);
|
||||
check(
|
||||
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/appearance.js`),
|
||||
"the API hostname refuses the Dashboard's pre-paint script",
|
||||
);
|
||||
const display = await fetch(`${apiOrigin}/display/does-not-exist`);
|
||||
check(
|
||||
display.status === 404 && (await display.text()) === '{"code":"NOT_FOUND"}',
|
||||
@@ -460,6 +481,38 @@ async function sessionChecks() {
|
||||
"the session resolves an authenticated principal through the proxy",
|
||||
);
|
||||
|
||||
// The person's own account (ADR 0062): who they are, and a preference that round-trips.
|
||||
const account = await fetch(`${apiOrigin}/api/v1/account`, {
|
||||
headers: { cookie, origin: dashboardOrigin },
|
||||
});
|
||||
const accountBody = await account.json();
|
||||
check(
|
||||
account.status === 200 &&
|
||||
accountBody.email === required("SMOKE_OWNER_EMAIL") &&
|
||||
Object.keys(accountBody).sort().join(",") === "email,name,preferences",
|
||||
"GET /api/v1/account returns the signed-in person's own name, email, and preferences only",
|
||||
);
|
||||
const changePreferences = async (body) => {
|
||||
const response = await fetch(`${apiOrigin}/api/v1/account/preferences`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
|
||||
method: "PATCH",
|
||||
});
|
||||
return { body: await response.json(), status: response.status };
|
||||
};
|
||||
const saved = await changePreferences({ motion: "reduce" });
|
||||
check(
|
||||
saved.status === 200 && saved.body.motion === "reduce",
|
||||
"PATCH /api/v1/account/preferences saves a change to the person's own account",
|
||||
);
|
||||
const restored = await changePreferences(accountBody.preferences);
|
||||
check(restored.status === 200, "the account's previous preferences are put back");
|
||||
const anonymousAccount = await fetch(`${apiOrigin}/api/v1/account`, {
|
||||
headers: { origin: dashboardOrigin },
|
||||
});
|
||||
await anonymousAccount.arrayBuffer();
|
||||
check(anonymousAccount.status === 403, "an anonymous caller is refused the account");
|
||||
|
||||
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
|
||||
body: "{}",
|
||||
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
|
||||
|
||||
@@ -607,8 +607,11 @@ if [[ "$topology" == split ]]; then
|
||||
docker rm --force "$browser_container" >/dev/null 2>&1 || true
|
||||
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
||||
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
||||
# Docker's default 64 MiB /dev/shm is too small for one tab that loads the Dashboard, a module,
|
||||
# and its live streams dozens of times: Chromium then fails requests with
|
||||
# ERR_INSUFFICIENT_RESOURCES partway through. A real desktop has far more.
|
||||
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp --shm-size 512m \
|
||||
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
|
||||
"$browser_image" \
|
||||
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
<!-- 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. -->
|
||||
<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>
|
||||
</head>
|
||||
<body
|
||||
|
||||
@@ -67,6 +67,27 @@ const buildOptions = {
|
||||
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 mkdir(outputDirectory, { recursive: true });
|
||||
await mkdir(path.join(outputDirectory, "display"), { recursive: true });
|
||||
@@ -87,6 +108,8 @@ await copyFile(
|
||||
);
|
||||
|
||||
if (serve) {
|
||||
const appearanceBootContext = await esbuild.context(appearanceBootOptions);
|
||||
await appearanceBootContext.watch();
|
||||
const context = await esbuild.context(buildOptions);
|
||||
await context.watch();
|
||||
// Any path with no file behind it gets the Dashboard page, so a refreshed deep link such as
|
||||
@@ -104,5 +127,6 @@ if (serve) {
|
||||
);
|
||||
} else {
|
||||
await esbuild.build(buildOptions);
|
||||
await esbuild.build(appearanceBootOptions);
|
||||
console.log(`Built the Ekklesia shell into ${path.relative(packageDirectory, outputDirectory)}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,22 +7,43 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { DashboardDestination } from "../dashboard-routes.js";
|
||||
import type { HostAccount } from "../host-protocol.js";
|
||||
import { AccountAvatar, accountDisplayName } from "./AccountAvatar.js";
|
||||
import { DashboardLink } from "./DashboardLink.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { useNavTooltip } from "./NavTooltip.js";
|
||||
import { controlHeight, type NavigationPresentation } from "./ShellNavigation.js";
|
||||
import { ShellIcon, type ShellIconName } from "./shell-icons.js";
|
||||
|
||||
export interface AccountMenuProps {
|
||||
readonly presentation: NavigationPresentation;
|
||||
readonly history: HostHistory;
|
||||
/** Who is signed in, or `"unavailable"` when their account could not be loaded: the control then
|
||||
* names the area rather than the person, and still offers every destination. */
|
||||
readonly account: HostAccount | "unavailable";
|
||||
readonly onSignOut: () => void;
|
||||
/** Called after choosing a destination, so a drawer around the menu can close. */
|
||||
readonly onNavigate?: () => void;
|
||||
}
|
||||
|
||||
interface AccountAction {
|
||||
type AccountAction =
|
||||
| {
|
||||
readonly kind: "link";
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
readonly testId: string;
|
||||
readonly to: DashboardDestination;
|
||||
}
|
||||
| {
|
||||
readonly kind: "button";
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly icon: ShellIconName;
|
||||
readonly testId: string;
|
||||
readonly run: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The signed-in person's account control, at the bottom of the navigation (DESIGN.md §4.5).
|
||||
@@ -33,21 +54,23 @@ interface AccountAction {
|
||||
* returns focus to the trigger; Tab or a pointer press outside closes it and leaves focus where it
|
||||
* went.
|
||||
*
|
||||
* It offers **only actions that work today** — Sign out. Profile, personal preferences, and
|
||||
* appearance belong here (DESIGN.md §4.5, §9.2) and arrive with the account pages; until then there
|
||||
* is no dead link to `/account`. The shell knows the person only by an opaque user id
|
||||
* (`/api/v1/principal`), which is never shown, so the control names the area rather than the person.
|
||||
* It shows **who is signed in** — their name and email beside their initials (ADR 0062) — and
|
||||
* offers only destinations that work: Account (`/account`), Appearance (`/account/appearance`), and
|
||||
* Sign out. Account and Appearance are real links, so they open in a new tab like any other. When
|
||||
* the account cannot be loaded, the control names the area ("Your account") rather than the person.
|
||||
*/
|
||||
export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const itemRefs = useRef<(HTMLElement | null)[]>([]);
|
||||
const menuId = useId();
|
||||
const triggerId = useId();
|
||||
const { collapsed } = props.presentation;
|
||||
const { tooltip, triggerProps } = useNavTooltip("Your account", collapsed && !open);
|
||||
const account = props.account === "unavailable" ? undefined : props.account;
|
||||
const name = account ? accountDisplayName(account) : "Your account";
|
||||
const { tooltip, triggerProps } = useNavTooltip(name, collapsed && !open);
|
||||
|
||||
const close = useCallback((returnFocus: boolean) => {
|
||||
setOpen(false);
|
||||
@@ -57,9 +80,26 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
}, []);
|
||||
|
||||
const actions: readonly AccountAction[] = [
|
||||
{
|
||||
icon: "account",
|
||||
id: "account",
|
||||
kind: "link",
|
||||
label: "Account",
|
||||
testId: "host-account-link",
|
||||
to: { kind: "account" },
|
||||
},
|
||||
{
|
||||
icon: "appearance",
|
||||
id: "appearance",
|
||||
kind: "link",
|
||||
label: "Appearance",
|
||||
testId: "host-appearance-link",
|
||||
to: { kind: "account-section", section: "appearance" },
|
||||
},
|
||||
{
|
||||
icon: "signOut",
|
||||
id: "sign-out",
|
||||
kind: "button",
|
||||
label: "Sign out",
|
||||
run: () => {
|
||||
setOpen(false);
|
||||
@@ -78,7 +118,7 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
|
||||
const items = itemRefs.current.filter((item): item is HTMLElement => item !== null);
|
||||
(initialFocus === "last" ? items.at(-1) : items[0])?.focus();
|
||||
}, [initialFocus, open]);
|
||||
|
||||
@@ -98,8 +138,8 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
}, [open]);
|
||||
|
||||
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const items = itemRefs.current.filter((item): item is HTMLButtonElement => item !== null);
|
||||
const index = items.indexOf(event.target as HTMLButtonElement);
|
||||
const items = itemRefs.current.filter((item): item is HTMLElement => item !== null);
|
||||
const index = items.indexOf(event.target as HTMLElement);
|
||||
const focusAt = (next: number): void => {
|
||||
event.preventDefault();
|
||||
items[(next + items.length) % items.length]?.focus();
|
||||
@@ -125,32 +165,68 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
{open ? (
|
||||
<div className="ek-shell-menu" data-testid="host-account-menu-popover">
|
||||
{account ? (
|
||||
<div className="ek-shell-menu__identity" data-testid="host-account-identity">
|
||||
<span className="ek-shell-menu__identity-name">{accountDisplayName(account)}</span>
|
||||
{account.name.trim().length > 0 ? (
|
||||
<span className="ek-shell-menu__identity-email">{account.email}</span>
|
||||
) : undefined}
|
||||
</div>
|
||||
) : undefined}
|
||||
<div
|
||||
aria-labelledby={triggerId}
|
||||
className="ek-shell-menu"
|
||||
className="ek-shell-menu__items"
|
||||
data-testid="host-account-menu-list"
|
||||
id={menuId}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
role="menu"
|
||||
>
|
||||
{actions.map((action, index) => (
|
||||
{actions.map((action, index) => {
|
||||
const ref = (element: HTMLElement | null): void => {
|
||||
itemRefs.current[index] = element;
|
||||
};
|
||||
const content = (
|
||||
<>
|
||||
<ShellIcon name={action.icon} />
|
||||
{action.label}
|
||||
</>
|
||||
);
|
||||
return action.kind === "link" ? (
|
||||
<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={(element) => {
|
||||
itemRefs.current[index] = element;
|
||||
}}
|
||||
ref={ref}
|
||||
role="menuitem"
|
||||
style={{ minHeight: controlHeight(props.presentation) }}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>
|
||||
<ShellIcon name={action.icon} />
|
||||
{action.label}
|
||||
{content}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined}
|
||||
<button
|
||||
@@ -176,9 +252,14 @@ export function AccountMenu(props: AccountMenuProps): ReactElement {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="account" />
|
||||
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-nav-item__label"}>
|
||||
Your account
|
||||
{account ? <AccountAvatar account={account} /> : <ShellIcon name="account" />}
|
||||
<span className={collapsed ? "ek-shell-visually-hidden" : "ek-shell-account-trigger__copy"}>
|
||||
<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>
|
||||
</button>
|
||||
{tooltip}
|
||||
|
||||
@@ -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,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
Ref,
|
||||
} from "react";
|
||||
import { type DashboardDestination, dashboardPath } from "../dashboard-routes.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
@@ -29,6 +30,11 @@ export interface DashboardLinkProps {
|
||||
readonly onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
|
||||
readonly onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
|
||||
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 = {
|
||||
@@ -100,7 +106,10 @@ export function DashboardLink(props: DashboardLinkProps): ReactElement {
|
||||
onKeyDown={props.onKeyDown}
|
||||
onMouseEnter={props.onMouseEnter}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
ref={props.ref}
|
||||
role={props.role}
|
||||
style={props.style ? { ...baseStyle, ...props.style } : baseStyle}
|
||||
tabIndex={props.tabIndex}
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
|
||||
@@ -25,10 +25,17 @@ import {
|
||||
bootstrapHost,
|
||||
type DashboardNavigationEntry,
|
||||
type EkklesiaHostConfig,
|
||||
type HostAccountPreferences,
|
||||
type HostBootstrap,
|
||||
HostProtocolError,
|
||||
loadHostPrincipal,
|
||||
} from "../host-protocol.js";
|
||||
import { AccountHomePage, AccountLoadFailedPage, AppearancePage } from "./AccountPages.js";
|
||||
import { createMemoryAppearanceCache } from "./appearance-cache.js";
|
||||
import {
|
||||
type AppearancePreference,
|
||||
createDetachedAppearancePreference,
|
||||
} from "./appearance-preference.js";
|
||||
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
|
||||
import type { HostHistory } from "./host-history.js";
|
||||
import { createBrowserHostViewport, type HostViewport } from "./host-viewport.js";
|
||||
@@ -142,6 +149,13 @@ export interface HostAppProps {
|
||||
* passes this browser's storage; left unset, the mode lasts only as long as this component.
|
||||
*/
|
||||
readonly sidebarPreference?: SidebarPreference;
|
||||
/**
|
||||
* The signed-in person's appearance and motion as this page applies them (ADR 0062). The entry
|
||||
* point passes one over the document's theme controller and this browser's pre-paint cache; left
|
||||
* unset, choices apply to nothing and last only as long as this component. Must be a stable
|
||||
* reference.
|
||||
*/
|
||||
readonly appearance?: AppearancePreference;
|
||||
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
@@ -278,8 +292,12 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
// Fallbacks for a composition that supplies neither; created once, so they stay stable.
|
||||
const [fallbackViewport] = useState(() => createBrowserHostViewport(undefined));
|
||||
const [fallbackPreference] = useState(() => createMemorySidebarPreference());
|
||||
const [fallbackAppearance] = useState(() =>
|
||||
createDetachedAppearancePreference(createMemoryAppearanceCache()),
|
||||
);
|
||||
const viewport = props.viewport ?? fallbackViewport;
|
||||
const sidebarPreference = props.sidebarPreference ?? fallbackPreference;
|
||||
const appearance = props.appearance ?? fallbackAppearance;
|
||||
const subscribe = useCallback((listener: () => void) => history.subscribe(listener), [history]);
|
||||
const readPathname = useCallback(() => history.pathname(), [history]);
|
||||
const pathname = useSyncExternalStore(subscribe, readPathname, readPathname);
|
||||
@@ -308,8 +326,38 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
[],
|
||||
);
|
||||
|
||||
const commitResolvedState = useCallback((resolved: ResolvedBootstrapState): void => {
|
||||
/**
|
||||
* Every resolved bootstrap reconciles the page's appearance with the account (ADR 0062): the
|
||||
* account's preferences replace whatever this browser cached, so a choice made on another device
|
||||
* is in effect here from this load on. A signed-out page follows the system and keeps no cached
|
||||
* choice, so the next person to use this browser does not inherit one. An account that could not
|
||||
* be loaded leaves the cached choice alone rather than guessing.
|
||||
*/
|
||||
const commitResolvedState = useCallback(
|
||||
(resolved: ResolvedBootstrapState): void => {
|
||||
if (resolved.status === "ready" && resolved.bootstrap.account !== "unavailable") {
|
||||
appearance.apply(resolved.bootstrap.account.preferences);
|
||||
} else if (resolved.status === "anonymous") {
|
||||
appearance.forget();
|
||||
}
|
||||
setState(resolved);
|
||||
},
|
||||
[appearance],
|
||||
);
|
||||
|
||||
/** A saved preference, as the account answered, kept in the bootstrap's copy of the account. */
|
||||
const recordSavedPreferences = useCallback((preferences: HostAccountPreferences): void => {
|
||||
setState((current) =>
|
||||
current.status === "ready" && current.bootstrap.account !== "unavailable"
|
||||
? {
|
||||
...current,
|
||||
bootstrap: {
|
||||
...current.bootstrap,
|
||||
account: { ...current.bootstrap.account, preferences },
|
||||
},
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// A non-canonical spelling of a real page (`/example/`) is corrected in place, adding no
|
||||
@@ -397,9 +445,12 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
// out while a verification or reset screen was the last anonymous view would show that screen
|
||||
// again, describing an emailed link the person has long since finished with.
|
||||
setAuthView({ kind: "sign-in" });
|
||||
setState(
|
||||
principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" },
|
||||
);
|
||||
if (principal.kind === "anonymous") {
|
||||
appearance.forget();
|
||||
setState({ status: "anonymous" });
|
||||
} else {
|
||||
setState({ status: "sign-out-failed" });
|
||||
}
|
||||
} catch {
|
||||
if (isCurrent(controller, generation)) {
|
||||
console.error("[ekklesia] sign out failed");
|
||||
@@ -410,7 +461,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
activeTransitionRef.current = undefined;
|
||||
}
|
||||
}
|
||||
}, [beginTransition, config, isCurrent]);
|
||||
}, [appearance, beginTransition, config, isCurrent]);
|
||||
|
||||
const onAuthorizationResponse = useCallback(
|
||||
(status: 401 | 403): void => {
|
||||
@@ -440,6 +491,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
transition.controller.abort();
|
||||
activeTransitionRef.current = undefined;
|
||||
setAuthView({ kind: "sign-in" });
|
||||
appearance.forget();
|
||||
setState({ status: "anonymous" });
|
||||
}
|
||||
})
|
||||
@@ -463,7 +515,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
}
|
||||
});
|
||||
},
|
||||
[beginTransition, config],
|
||||
[appearance, beginTransition, config],
|
||||
);
|
||||
|
||||
const openEntry =
|
||||
@@ -491,6 +543,10 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
? "Overview"
|
||||
: route.kind === "module"
|
||||
? (openEntry?.name ?? "This page isn’t available")
|
||||
: route.kind === "account"
|
||||
? "Account"
|
||||
: route.kind === "account-section"
|
||||
? "Appearance"
|
||||
: "Page not found";
|
||||
const documentTitle = state.status === "anonymous" ? "Sign in" : pageTitle;
|
||||
|
||||
@@ -609,6 +665,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
config={config}
|
||||
history={history}
|
||||
mainRef={mainRef}
|
||||
account={bootstrap.account}
|
||||
navigation={bootstrap.navigation}
|
||||
onSignOut={() => void signOut()}
|
||||
pageTitle={pageTitle}
|
||||
@@ -619,6 +676,25 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
>
|
||||
{settingsPage !== undefined ? (
|
||||
<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" ? (
|
||||
<OverviewPage />
|
||||
) : route.kind === "module" ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
DashboardNavigation,
|
||||
DashboardSettings,
|
||||
EkklesiaHostConfig,
|
||||
HostAccount,
|
||||
} from "../host-protocol.js";
|
||||
import { AccountMenu } from "./AccountMenu.js";
|
||||
import { DevicePanel } from "./DevicePanel.js";
|
||||
@@ -45,6 +46,7 @@ export interface ShellLayoutProps {
|
||||
readonly history: HostHistory;
|
||||
readonly navigation: DashboardNavigation;
|
||||
readonly settings: DashboardSettings | "unavailable";
|
||||
readonly account: HostAccount | "unavailable";
|
||||
readonly route: DashboardRoute;
|
||||
readonly viewport: HostViewport;
|
||||
readonly sidebarPreference: SidebarPreference;
|
||||
@@ -89,6 +91,7 @@ function Sidebar(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly navigation: DashboardNavigation;
|
||||
readonly settings: DashboardSettings | "unavailable";
|
||||
readonly account: HostAccount | "unavailable";
|
||||
readonly route: DashboardRoute;
|
||||
readonly mode: SidebarMode;
|
||||
readonly onToggle: () => void;
|
||||
@@ -123,7 +126,12 @@ function Sidebar(props: {
|
||||
{presentation.collapsed ? undefined : <DevicePanel config={props.config} />}
|
||||
</div>
|
||||
<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>
|
||||
</aside>
|
||||
);
|
||||
@@ -143,6 +151,7 @@ function NavigationDrawer(props: {
|
||||
readonly history: HostHistory;
|
||||
readonly navigation: DashboardNavigation;
|
||||
readonly settings: DashboardSettings | "unavailable";
|
||||
readonly account: HostAccount | "unavailable";
|
||||
readonly route: DashboardRoute;
|
||||
readonly onDismiss: () => void;
|
||||
readonly onNavigate: () => void;
|
||||
@@ -226,7 +235,13 @@ function NavigationDrawer(props: {
|
||||
<DevicePanel config={props.config} />
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
@@ -325,6 +340,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
<style>{SHELL_STYLESHEET}</style>
|
||||
{compact ? undefined : (
|
||||
<Sidebar
|
||||
account={props.account}
|
||||
config={props.config}
|
||||
history={props.history}
|
||||
mode={mode}
|
||||
@@ -376,6 +392,7 @@ export function ShellLayout(props: ShellLayoutProps): ReactElement {
|
||||
</div>
|
||||
{showDrawer ? (
|
||||
<NavigationDrawer
|
||||
account={props.account}
|
||||
config={props.config}
|
||||
history={props.history}
|
||||
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) },
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { EkklesiaUiStyles } from "@ekklesia/ui";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { consumeAuthLanding } from "../auth-protocol.js";
|
||||
import { type EkklesiaHostConfig, resolveApiOrigin } from "../host-protocol.js";
|
||||
import { APPEARANCE_CACHE_KEY, createLocalAppearanceCache } from "./appearance-cache.js";
|
||||
import { createAppearancePreference } from "./appearance-preference.js";
|
||||
import { HostApp } from "./HostApp.js";
|
||||
import { createBrowserHostHistory } from "./host-history.js";
|
||||
import { createBrowserHostViewport } from "./host-viewport.js";
|
||||
@@ -36,21 +38,25 @@ const setDocumentTitle = (title: string): void => {
|
||||
document.title = title;
|
||||
};
|
||||
|
||||
/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). Merely
|
||||
* reading `localStorage` throws where site data is blocked; the shell then remembers the sidebar's
|
||||
* mode for this page only. */
|
||||
const viewport = createBrowserHostViewport(
|
||||
typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined,
|
||||
);
|
||||
const sidebarPreference = createLocalSidebarPreference(
|
||||
(() => {
|
||||
/**
|
||||
* This browser's storage, read once. It holds exactly two things: the desktop sidebar's mode
|
||||
* (ADR 0058) and the pre-paint copy of the person's appearance (ADR 0062), which is a cache of what
|
||||
* their account holds and never the source of truth. Merely reading `localStorage` throws where site
|
||||
* data is blocked; the shell then remembers both for this page only.
|
||||
*/
|
||||
const browserStorage = (() => {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})(),
|
||||
})();
|
||||
|
||||
/** The phone-layout media query and this browser's storage, for the sidebar (ADR 0058). */
|
||||
const viewport = createBrowserHostViewport(
|
||||
typeof window.matchMedia === "function" ? (query) => window.matchMedia(query) : undefined,
|
||||
);
|
||||
const sidebarPreference = createLocalSidebarPreference(browserStorage);
|
||||
|
||||
const container = document.getElementById("ekklesia-host");
|
||||
if (!container) {
|
||||
@@ -69,22 +75,42 @@ if (!container) {
|
||||
installEkklesiaDesignTokens(document);
|
||||
|
||||
/**
|
||||
* The Dashboard's appearance and motion (ADR 0060), applied to the document root before React
|
||||
* renders anything.
|
||||
* The Dashboard's appearance and motion (ADR 0060, ADR 0062), applied to the document root before
|
||||
* React renders anything.
|
||||
*
|
||||
* Both are `system` until a person chooses otherwise: the token stylesheet then follows the
|
||||
* operating system's light/dark and reduced-motion settings live, by CSS alone, into every module
|
||||
* root. This is the one place a remembered choice will be supplied: the account preferences work
|
||||
* reads it synchronously and passes it here, so an explicit Light or Dark is in place before the
|
||||
* first frame. Nothing is stored yet, and there is no second, temporary way to choose.
|
||||
* The page's pre-paint script (`appearance-boot.ts`) has already put this browser's cached choice
|
||||
* on the root, before the body was parsed; the controller starts from that same cache, so the two
|
||||
* agree and nothing changes here. Without a cache both are `system`, and the token stylesheet
|
||||
* follows the operating system's light/dark and reduced-motion settings live, by CSS alone, into
|
||||
* every module root. Once the account answers, the shell applies what it holds (`HostApp`).
|
||||
*/
|
||||
createEkklesiaThemeController({
|
||||
appearance: "system",
|
||||
const appearanceCache = createLocalAppearanceCache(browserStorage);
|
||||
const cachedAppearance = appearanceCache.read();
|
||||
const appearance = createAppearancePreference({
|
||||
cache: appearanceCache,
|
||||
...(() => {
|
||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]');
|
||||
return meta ? { colorSchemeMeta: meta } : {};
|
||||
})(),
|
||||
theme: createEkklesiaThemeController({
|
||||
appearance: cachedAppearance?.appearance ?? "system",
|
||||
...(typeof window.matchMedia === "function"
|
||||
? { matchMedia: (query: string) => window.matchMedia(query) }
|
||||
: {}),
|
||||
motion: "system",
|
||||
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 ? (
|
||||
<HostApp
|
||||
appearance={appearance}
|
||||
authLanding={authLanding}
|
||||
config={config}
|
||||
history={history}
|
||||
@@ -111,6 +138,7 @@ function renderHost(config: EkklesiaHostConfig | undefined) {
|
||||
/>
|
||||
) : (
|
||||
<HostApp
|
||||
appearance={appearance}
|
||||
config={config}
|
||||
history={history}
|
||||
setDocumentTitle={setDocumentTitle}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Server,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
SunMoon,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ReactElement } from "react";
|
||||
@@ -34,6 +35,7 @@ import type { ReactElement } from "react";
|
||||
*/
|
||||
export const SHELL_ICONS = Object.freeze({
|
||||
account: CircleUserRound,
|
||||
appearance: SunMoon,
|
||||
closeNavigation: X,
|
||||
collapseSidebar: PanelLeftClose,
|
||||
discoverBrowse: Search,
|
||||
|
||||
@@ -209,9 +209,12 @@ export const SHELL_STYLESHEET = `
|
||||
gap: ${designToken("spacing.2xs")};
|
||||
left: 0;
|
||||
margin: 0;
|
||||
max-width: min(20rem, calc(100vw - 2 * ${designToken("spacing.md")}));
|
||||
min-width: 12rem;
|
||||
padding: ${designToken("spacing.xs")};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -227,6 +230,126 @@ export const SHELL_STYLESHEET = `
|
||||
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 {
|
||||
padding: ${designToken("spacing.sm")} ${designToken("spacing.md")} 0;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
* 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
|
||||
* 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);
|
||||
|
||||
export const dashboardHostAssetUrls = Object.freeze({
|
||||
html: new URL("dist/browser/index.html", packageRoot).href,
|
||||
script: new URL("dist/browser/assets/ekklesia-dashboard/app.js", packageRoot).href,
|
||||
appearance: new URL("dist/browser/assets/ekklesia-dashboard/appearance.js", packageRoot).href,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
* /settings/<section> a platform Settings section: general, branding, displays, system
|
||||
* /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)
|
||||
* /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
|
||||
* ```
|
||||
*
|
||||
@@ -50,6 +51,15 @@ export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
|
||||
/** The sections that are one page each. Discover is a group of views instead. */
|
||||
export type SettingsPageSection = Exclude<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
|
||||
* manage installed modules), and Updates (module and platform updates). Browse is where Discover
|
||||
@@ -67,6 +77,8 @@ export type DashboardRoute =
|
||||
| { readonly kind: "settings-section"; readonly section: SettingsPageSection }
|
||||
| { readonly kind: "discover"; readonly view: DiscoverView }
|
||||
| { readonly kind: "module-settings"; readonly moduleId: string }
|
||||
| { readonly kind: "account" }
|
||||
| { readonly kind: "account-section"; readonly section: AccountSection }
|
||||
| { readonly kind: "not-found" };
|
||||
|
||||
/** A route the shell can link to. `not-found` is something the shell arrives at, never a target. */
|
||||
@@ -76,6 +88,7 @@ const platformPages: ReadonlySet<string> = new Set(DASHBOARD_PLATFORM_PAGES);
|
||||
const settingsSections: ReadonlySet<string> = new Set(SETTINGS_SECTIONS);
|
||||
|
||||
const discoverViews: ReadonlySet<string> = new Set(DISCOVER_VIEWS);
|
||||
const accountSections: ReadonlySet<string> = new Set(ACCOUNT_SECTIONS);
|
||||
|
||||
function isSettingsPageSection(value: string): value is SettingsPageSection {
|
||||
return value !== "discover" && settingsSections.has(value);
|
||||
@@ -85,6 +98,10 @@ function isDiscoverView(value: string): value is DiscoverView {
|
||||
return discoverViews.has(value);
|
||||
}
|
||||
|
||||
function isAccountSection(value: string): value is AccountSection {
|
||||
return accountSections.has(value);
|
||||
}
|
||||
|
||||
function decodeSegment(segment: string): string | undefined {
|
||||
try {
|
||||
const decoded = decodeURIComponent(segment);
|
||||
@@ -120,6 +137,9 @@ export function parseDashboardPath(pathname: string): DashboardRoute {
|
||||
if (first === "settings") {
|
||||
return parseSettingsPath(meaningful.slice(1));
|
||||
}
|
||||
if (first === "account") {
|
||||
return parseAccountPath(meaningful.slice(1));
|
||||
}
|
||||
if (first === undefined || platformPages.has(first)) {
|
||||
return { kind: "not-found" };
|
||||
}
|
||||
@@ -161,6 +181,18 @@ function parseSettingsPath(rest: readonly string[]): DashboardRoute {
|
||||
: { kind: "module-settings", moduleId: name };
|
||||
}
|
||||
|
||||
/** Reads what follows `/account`: the account page itself, or one of its personal settings pages.
|
||||
* Anything else is `not-found` until it exists. */
|
||||
function parseAccountPath(rest: readonly string[]): DashboardRoute {
|
||||
if (rest.length === 0) {
|
||||
return { kind: "account" };
|
||||
}
|
||||
const name = rest[0] === undefined ? undefined : decodeSegment(rest[0]);
|
||||
return rest.length === 1 && name !== undefined && isAccountSection(name)
|
||||
? { kind: "account-section", section: name }
|
||||
: { kind: "not-found" };
|
||||
}
|
||||
|
||||
/** The one canonical spelling of a route. A module id is percent-encoded, so no id can escape its
|
||||
* own path segment. */
|
||||
export function dashboardPath(route: DashboardDestination): string {
|
||||
@@ -177,5 +209,9 @@ export function dashboardPath(route: DashboardDestination): string {
|
||||
return `/settings/discover/${route.view}`;
|
||||
case "module-settings":
|
||||
return `/settings/${encodeURIComponent(route.moduleId)}`;
|
||||
case "account":
|
||||
return "/account";
|
||||
case "account-section":
|
||||
return `/account/${route.section}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js";
|
||||
import {
|
||||
EKKLESIA_APPEARANCES,
|
||||
EKKLESIA_MOTION_PREFERENCES,
|
||||
type EkklesiaAppearance,
|
||||
type EkklesiaMotionPreference,
|
||||
} from "@ekklesia/design-tokens";
|
||||
import type { ModuleUiPrincipal } from "@ekklesia/module-ui-sdk";
|
||||
import { SETTINGS_SECTIONS, type SettingsSection } from "./dashboard-routes.js";
|
||||
|
||||
/**
|
||||
* How this shell reaches the Ekklesia API.
|
||||
@@ -39,6 +45,8 @@ const PRINCIPAL_PATH = "/api/v1/principal";
|
||||
const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation";
|
||||
const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings";
|
||||
const MODULE_DISPLAYS_PATH = "/api/v1/module-displays";
|
||||
const ACCOUNT_PATH = "/api/v1/account";
|
||||
const ACCOUNT_PREFERENCES_PATH = "/api/v1/account/preferences";
|
||||
|
||||
const MODULE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
||||
const MODULE_ID_MAX_LENGTH = 64;
|
||||
@@ -404,6 +412,7 @@ async function requestPlatformMutation(
|
||||
rootRelativePath: string,
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
method: "PATCH" | "POST" = "POST",
|
||||
): Promise<unknown> {
|
||||
const performRequest = config.fetch ?? fetch;
|
||||
let response: Response;
|
||||
@@ -413,7 +422,7 @@ async function requestPlatformMutation(
|
||||
cache: "no-store",
|
||||
credentials: "include",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
method: "POST",
|
||||
method,
|
||||
redirect: "error",
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
@@ -935,6 +944,79 @@ export async function loadDashboardSettings(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A signed-in person's own appearance and motion preferences, as their account stores them
|
||||
* (ADR 0062). The value sets are the design tokens' own, so what the account stores is exactly what
|
||||
* the theme controller applies.
|
||||
*/
|
||||
export interface HostAccountPreferences {
|
||||
readonly appearance: EkklesiaAppearance;
|
||||
readonly motion: EkklesiaMotionPreference;
|
||||
}
|
||||
|
||||
/** Who is signed in, as the account area shows them: their name and email, and their preferences.
|
||||
* Nothing else about the account reaches the browser. */
|
||||
export interface HostAccount {
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly preferences: HostAccountPreferences;
|
||||
}
|
||||
|
||||
/** Strict: exactly the two preferences, each one of its known values. */
|
||||
export function parseHostAccountPreferences(value: unknown): HostAccountPreferences {
|
||||
if (
|
||||
!hasExactKeys(value, ["appearance", "motion"]) ||
|
||||
!EKKLESIA_APPEARANCES.includes(value.appearance as EkklesiaAppearance) ||
|
||||
!EKKLESIA_MOTION_PREFERENCES.includes(value.motion as EkklesiaMotionPreference)
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({
|
||||
appearance: value.appearance as EkklesiaAppearance,
|
||||
motion: value.motion as EkklesiaMotionPreference,
|
||||
});
|
||||
}
|
||||
|
||||
/** Strict: exactly name, email, and preferences. A name or email is rendered as text, never as
|
||||
* markup or a URL, so the only requirement is that each is a string with something in it. */
|
||||
export function parseHostAccount(value: unknown): HostAccount {
|
||||
if (
|
||||
!hasExactKeys(value, ["email", "name", "preferences"]) ||
|
||||
typeof value.name !== "string" ||
|
||||
typeof value.email !== "string" ||
|
||||
value.email.trim().length === 0
|
||||
) {
|
||||
throw new HostProtocolError("HOST_RESPONSE_INVALID");
|
||||
}
|
||||
return Object.freeze({
|
||||
email: value.email,
|
||||
name: value.name,
|
||||
preferences: parseHostAccountPreferences(value.preferences),
|
||||
});
|
||||
}
|
||||
|
||||
/** The signed-in person's own account. There is no way to ask for anyone else's. */
|
||||
export async function loadHostAccount(
|
||||
config: EkklesiaHostConfig,
|
||||
signal?: AbortSignal,
|
||||
): Promise<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 {
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
readonly navigation: DashboardNavigation;
|
||||
@@ -944,6 +1026,13 @@ export interface HostBootstrap {
|
||||
* is withheld and its pages say they could not be loaded.
|
||||
*/
|
||||
readonly settings: DashboardSettings | "unavailable";
|
||||
/**
|
||||
* The signed-in person's own account, or `"unavailable"` when it could not be loaded (and for an
|
||||
* anonymous viewer, who has none). Like Settings, an account that cannot be loaded never stops the
|
||||
* Dashboard: the account control shows no name, the theme stays as this browser last knew it, and
|
||||
* the account pages say they could not be loaded.
|
||||
*/
|
||||
readonly account: HostAccount | "unavailable";
|
||||
}
|
||||
|
||||
const EMPTY_SETTINGS: DashboardSettings = Object.freeze({
|
||||
@@ -968,9 +1057,14 @@ export async function bootstrapHost(
|
||||
): Promise<HostBootstrap> {
|
||||
const principal = await loadHostPrincipal(config, signal);
|
||||
if (principal.kind === "anonymous") {
|
||||
return { navigation: EMPTY_NAVIGATION, principal, settings: EMPTY_SETTINGS };
|
||||
return {
|
||||
account: "unavailable",
|
||||
navigation: EMPTY_NAVIGATION,
|
||||
principal,
|
||||
settings: EMPTY_SETTINGS,
|
||||
};
|
||||
}
|
||||
const [navigation, settings] = await Promise.all([
|
||||
const [navigation, settings, account] = await Promise.all([
|
||||
loadDashboardNavigation(config, signal),
|
||||
loadDashboardSettings(config, signal).catch((error: unknown) => {
|
||||
if (signal?.aborted) {
|
||||
@@ -979,6 +1073,13 @@ export async function bootstrapHost(
|
||||
console.error("[ekklesia] settings navigation unavailable", error);
|
||||
return "unavailable" as const;
|
||||
}),
|
||||
loadHostAccount(config, signal).catch((error: unknown) => {
|
||||
if (signal?.aborted) {
|
||||
throw error;
|
||||
}
|
||||
console.error("[ekklesia] account unavailable", error);
|
||||
return "unavailable" as const;
|
||||
}),
|
||||
]);
|
||||
return { navigation, principal, settings };
|
||||
return { account, navigation, principal, settings };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export {
|
||||
type DashboardNavigationGroup,
|
||||
type DisplayHostBootstrap,
|
||||
type EkklesiaHostConfig,
|
||||
type HostAccount,
|
||||
type HostAccountPreferences,
|
||||
type HostBootstrap,
|
||||
type HostEventSourceFactory,
|
||||
type HostEventSourceOptions,
|
||||
@@ -28,16 +30,20 @@ export {
|
||||
type HostProtocolErrorCode,
|
||||
loadDashboardNavigation,
|
||||
loadDashboardSettings,
|
||||
loadHostAccount,
|
||||
loadHostPrincipal,
|
||||
loadModuleDisplay,
|
||||
moduleDisplayDescriptorPath,
|
||||
parseDashboardNavigation,
|
||||
parseDashboardSettings,
|
||||
parseDisplayRoute,
|
||||
parseHostAccount,
|
||||
parseHostAccountPreferences,
|
||||
parseHostModuleDisplay,
|
||||
parseHostPrincipal,
|
||||
resolveApiOrigin,
|
||||
resolveApiUrl,
|
||||
saveHostAccountPreferences,
|
||||
} from "./host-protocol.js";
|
||||
export {
|
||||
type CreateModuleUiContextOptions,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,13 @@ import { dashboardHostAssetUrls } from "../src/dashboard-assets.js";
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
describe("Dashboard host asset locator", () => {
|
||||
it("exports only frozen immutable href strings for the two exact built files, never the source map", () => {
|
||||
it("exports only frozen immutable href strings for the three exact built files, never the source map", () => {
|
||||
expect(Object.isFrozen(dashboardHostAssetUrls)).toBe(true);
|
||||
expect(dashboardHostAssetUrls).toEqual({
|
||||
appearance: new URL(
|
||||
"dist/browser/assets/ekklesia-dashboard/appearance.js",
|
||||
`file://${packageDirectory}/`,
|
||||
).href,
|
||||
html: new URL("dist/browser/index.html", `file://${packageDirectory}/`).href,
|
||||
script: new URL(
|
||||
"dist/browser/assets/ekklesia-dashboard/app.js",
|
||||
@@ -43,14 +47,28 @@ describe("Dashboard host asset locator", () => {
|
||||
expect(html).toContain("color: var(--ekklesia-text-primary);");
|
||||
});
|
||||
|
||||
it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => {
|
||||
it("loads exactly two fixed, absolute, same-origin scripts and no inline script", async () => {
|
||||
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
|
||||
|
||||
expect(html).toContain('id="ekklesia-host"');
|
||||
expect(html.match(/<script\b/gu)).toHaveLength(1);
|
||||
expect(html.match(/<script\b/gu)).toHaveLength(2);
|
||||
expect(html).toContain(
|
||||
'<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 () => {
|
||||
@@ -76,5 +94,6 @@ describe("Dashboard host asset locator", () => {
|
||||
);
|
||||
|
||||
expect(buildScript).toContain('"assets/ekklesia-dashboard/app": path.join(');
|
||||
expect(buildScript).toContain('"assets/ekklesia-dashboard/appearance": path.join(');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACCOUNT_SECTIONS,
|
||||
DASHBOARD_PLATFORM_PAGES,
|
||||
dashboardPath,
|
||||
parseDashboardPath,
|
||||
@@ -37,8 +38,35 @@ describe("parseDashboardPath", () => {
|
||||
|
||||
it("never reads a platform page as a module page", () => {
|
||||
expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" });
|
||||
for (const pathname of ["/account", "/account/appearance"]) {
|
||||
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
|
||||
expect(parseDashboardPath("/account")).toEqual({ kind: "account" });
|
||||
});
|
||||
|
||||
it("reads the account area: the account page and its personal settings pages (ADR 0062)", () => {
|
||||
expect(parseDashboardPath("/account")).toEqual({ kind: "account" });
|
||||
expect(parseDashboardPath("/account/")).toEqual({ kind: "account" });
|
||||
expect(parseDashboardPath("/account/appearance")).toEqual({
|
||||
kind: "account-section",
|
||||
section: "appearance",
|
||||
});
|
||||
expect(parseDashboardPath("/account/appearance/")).toEqual({
|
||||
kind: "account-section",
|
||||
section: "appearance",
|
||||
});
|
||||
expect(dashboardPath({ kind: "account" })).toBe("/account");
|
||||
expect(dashboardPath({ kind: "account-section", section: "appearance" })).toBe(
|
||||
"/account/appearance",
|
||||
);
|
||||
expect(ACCOUNT_SECTIONS).toEqual(["appearance"]);
|
||||
for (const pathname of [
|
||||
"/account/profile",
|
||||
"/account/appearance/extra",
|
||||
"/account/Appearance",
|
||||
"/account//appearance",
|
||||
"/accounts",
|
||||
]) {
|
||||
expect(parseDashboardPath(pathname), pathname).toEqual(
|
||||
pathname === "/accounts" ? { kind: "module", moduleId: "accounts" } : { kind: "not-found" },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -484,6 +484,8 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
"/api/v1/dashboard/navigation",
|
||||
// Which Settings destinations the person may see, asked alongside the navigation (ADR 0061).
|
||||
"/api/v1/dashboard/settings",
|
||||
// Who is signed in and their own preferences, asked alongside them too (ADR 0062).
|
||||
"/api/v1/account",
|
||||
// The host-owned device panel loads with the rest of the shell for an administrator.
|
||||
"/api/v1/devices",
|
||||
]);
|
||||
|
||||
@@ -4,11 +4,15 @@ import {
|
||||
type HostModuleUiDescriptor,
|
||||
HostProtocolError,
|
||||
loadDashboardNavigation,
|
||||
loadHostAccount,
|
||||
loadHostPrincipal,
|
||||
parseDashboardNavigation,
|
||||
parseDashboardSettings,
|
||||
parseHostAccount,
|
||||
parseHostAccountPreferences,
|
||||
parseHostPrincipal,
|
||||
resolveApiOrigin,
|
||||
saveHostAccountPreferences,
|
||||
} from "../src/host-protocol.js";
|
||||
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
|
||||
|
||||
@@ -37,6 +41,12 @@ function stubFetch(routes: Readonly<Record<string, () => Response>>) {
|
||||
return { calls, implementation: implementation as unknown as typeof fetch };
|
||||
}
|
||||
|
||||
const accountPayload = {
|
||||
email: "[email protected]",
|
||||
name: "Ada Lovelace",
|
||||
preferences: { appearance: "dark", motion: "reduce" },
|
||||
};
|
||||
|
||||
const inventoryPayload = {
|
||||
modules: [
|
||||
{
|
||||
@@ -304,6 +314,7 @@ describe("host protocol — bootstrap", () => {
|
||||
sections: ["displays"],
|
||||
}),
|
||||
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
|
||||
"/api/v1/account": () => jsonResponse(accountPayload),
|
||||
});
|
||||
|
||||
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
||||
@@ -312,7 +323,9 @@ describe("host protocol — bootstrap", () => {
|
||||
`${apiBaseUrl}/api/v1/principal`,
|
||||
`${apiBaseUrl}/api/v1/dashboard/navigation`,
|
||||
`${apiBaseUrl}/api/v1/dashboard/settings`,
|
||||
`${apiBaseUrl}/api/v1/account`,
|
||||
]);
|
||||
expect(bootstrap.account).toEqual(accountPayload);
|
||||
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
|
||||
expect(bootstrap.settings).toEqual({
|
||||
@@ -571,3 +584,106 @@ describe("module UI host context", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host protocol — the signed-in person's account (ADR 0062)", () => {
|
||||
it("reads the account strictly: exactly name, email, and two known preferences", () => {
|
||||
expect(parseHostAccount(accountPayload)).toEqual(accountPayload);
|
||||
for (const payload of [
|
||||
null,
|
||||
{},
|
||||
{ ...accountPayload, image: "https://tracker.example.test/me.png" },
|
||||
{ ...accountPayload, userId: "user-1" },
|
||||
{ ...accountPayload, email: "" },
|
||||
{ ...accountPayload, email: 1 },
|
||||
{ ...accountPayload, name: null },
|
||||
{ ...accountPayload, preferences: { appearance: "sepia", motion: "reduce" } },
|
||||
{ ...accountPayload, preferences: { appearance: "dark", motion: "none" } },
|
||||
{ ...accountPayload, preferences: { appearance: "dark" } },
|
||||
{ ...accountPayload, preferences: { appearance: "dark", motion: "allow", radius: "lg" } },
|
||||
]) {
|
||||
expect(() => parseHostAccount(payload), JSON.stringify(payload)).toThrowError(
|
||||
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["system", "system"],
|
||||
["light", "reduce"],
|
||||
["dark", "allow"],
|
||||
] as const)("accepts appearance %s with motion %s", (appearance, motion) => {
|
||||
expect(parseHostAccountPreferences({ appearance, motion })).toEqual({ appearance, motion });
|
||||
});
|
||||
|
||||
it("loads the account from its one platform path, with credentials, naming nobody", async () => {
|
||||
const { calls, implementation } = stubFetch({
|
||||
"/api/v1/account": () => jsonResponse(accountPayload),
|
||||
});
|
||||
|
||||
expect(await loadHostAccount({ apiBaseUrl, fetch: implementation })).toEqual(accountPayload);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/account`);
|
||||
expect(calls[0]?.init?.credentials).toBe("include");
|
||||
});
|
||||
|
||||
it("saves a change with one credentialed PATCH of exactly the change, and returns the server's answer", async () => {
|
||||
const { calls, implementation } = stubFetch({
|
||||
"/api/v1/account/preferences": () => jsonResponse({ appearance: "light", motion: "reduce" }),
|
||||
});
|
||||
|
||||
const saved = await saveHostAccountPreferences(
|
||||
{ apiBaseUrl, fetch: implementation },
|
||||
{ appearance: "light" },
|
||||
);
|
||||
|
||||
expect(saved).toEqual({ appearance: "light", motion: "reduce" });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/account/preferences`);
|
||||
expect(calls[0]?.init?.method).toBe("PATCH");
|
||||
expect(calls[0]?.init?.credentials).toBe("include");
|
||||
expect(calls[0]?.init?.redirect).toBe("error");
|
||||
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ appearance: "light" });
|
||||
});
|
||||
|
||||
it("reports a refused save as signed out, and a failed one as unreachable", async () => {
|
||||
for (const [status, code] of [
|
||||
[403, "HOST_UNAUTHENTICATED"],
|
||||
[503, "HOST_REQUEST_FAILED"],
|
||||
[400, "HOST_REQUEST_FAILED"],
|
||||
] as const) {
|
||||
const { implementation } = stubFetch({
|
||||
"/api/v1/account/preferences": () => jsonResponse({ code: "X" }, status),
|
||||
});
|
||||
await expect(
|
||||
saveHostAccountPreferences({ apiBaseUrl, fetch: implementation }, { motion: "allow" }),
|
||||
).rejects.toMatchObject({ code });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the Dashboard working when the account cannot be loaded", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const { implementation } = stubFetch({
|
||||
"/api/v1/account": () => jsonResponse({ code: "ACCOUNT_READ_FAILED" }, 503),
|
||||
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
|
||||
"/api/v1/dashboard/settings": () => jsonResponse({ modules: [], sections: [] }),
|
||||
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
|
||||
});
|
||||
|
||||
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
||||
|
||||
expect(bootstrap.account).toBe("unavailable");
|
||||
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("never asks for an account on behalf of an anonymous viewer", async () => {
|
||||
const { calls, implementation } = stubFetch({
|
||||
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
|
||||
});
|
||||
|
||||
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
||||
|
||||
expect(bootstrap.account).toBe("unavailable");
|
||||
expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe("the Dashboard opens on the page its URL names", () => {
|
||||
});
|
||||
|
||||
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
|
||||
for (const path of ["/settings/displays/add", "/calendar/events", "/account"]) {
|
||||
for (const path of ["/settings/displays/add", "/calendar/events", "/account/profile"]) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -384,12 +384,13 @@ describe("the account area", () => {
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.textContent).toContain("Your account");
|
||||
// Only the opaque user id is known to the shell, and it is never shown.
|
||||
// This harness serves no account, so the control names the area; the opaque user id is never
|
||||
// shown either way. With an account it names the person (account-appearance.test.tsx).
|
||||
expect(container.textContent).not.toContain("user-1");
|
||||
expect(query("host-account-menu-list")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens on activation with focus on its first item, and offers only actions that work today", async () => {
|
||||
it("opens on activation with focus on its first item, and offers only destinations that work", async () => {
|
||||
await render();
|
||||
const trigger = required("host-account-menu");
|
||||
|
||||
@@ -400,9 +401,14 @@ describe("the account area", () => {
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(trigger.getAttribute("aria-controls")).toBe(menu.id);
|
||||
const items = [...menu.querySelectorAll('[role="menuitem"]')];
|
||||
expect(items.map((item) => item.textContent)).toEqual(["Sign out"]);
|
||||
expect(items.map((item) => item.textContent)).toEqual(["Account", "Appearance", "Sign out"]);
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
expect(menu.querySelector("a")).toBeNull();
|
||||
// Account and Appearance are real links to real pages (ADR 0062); Sign out is an action.
|
||||
expect([...menu.querySelectorAll("a")].map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/account",
|
||||
"/account/appearance",
|
||||
]);
|
||||
expect(items[2]?.tagName).toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("opens from the keyboard with ArrowDown, and Escape closes it and returns focus", async () => {
|
||||
@@ -411,6 +417,8 @@ describe("the account area", () => {
|
||||
await focus(trigger);
|
||||
|
||||
await key(trigger, "ArrowDown");
|
||||
expect(document.activeElement).toBe(required("host-account-link"));
|
||||
await key(required("host-account-link"), "ArrowUp");
|
||||
expect(document.activeElement).toBe(required("host-logout"));
|
||||
|
||||
await key(required("host-logout"), "Escape");
|
||||
|
||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
"core/tests/authorization-store.integration.ts",
|
||||
"core/tests/authorization-bootstrap.integration.ts",
|
||||
"core/tests/dashboard-store.integration.ts",
|
||||
"core/tests/account-store.integration.ts",
|
||||
"core/tests/module-lifecycle-store.integration.ts",
|
||||
"core/tests/module-configuration-store.integration.ts",
|
||||
"core/tests/module-storage.integration.ts",
|
||||
|
||||
Reference in New Issue
Block a user