Add the account area and account-synced Appearance preferences
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s

Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance
chooses System, Light, or Dark and reduced motion (System, Reduce, Allow).

- Core: ekklesia_core_user_preference, one typed, CHECK-constrained,
  nullable column per preference (null = never chosen), migration
  0008_user_preferences (additive), @ekklesia/core/account store, and the
  authenticated-baseline permission core.account.self.manage.
- API: GET /api/v1/account (name, email, preferences) and partial
  PATCH /api/v1/account/preferences, self-only by construction: no user id
  is accepted anywhere; trusted Origin and body limit on the mutation.
- Shell: the account control shows name, email, and initials (never the
  unvetted image URL) and offers Account, Appearance, Sign out; native
  radio groups apply a choice at once through the ADR 0060 controller and
  save it; the server's answer wins and a failed save reverts.
- First paint: a classic, same-origin, render-blocking appearance.js in
  <head> applies a two-word localStorage cache before paint (no inline
  script, CSP unchanged). The account replaces the cache on every load,
  sign-out clears it, and other tabs follow via the storage event.
- Deploy smoke: real-Chromium checks for light/dark, OS emulation,
  overrides, reload persistence, pre-paint, Shadow DOM modules, reduced
  motion, the display staying light, and phone width; the browser
  container gets a 512 MiB /dev/shm so a long session no longer fails
  requests with ERR_INSUFFICIENT_RESOURCES.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-14 23:56:50 -05:00
co-authored by Claude Opus 5.5
parent 8571e6854c
commit cd0287537e
88 changed files with 6404 additions and 158 deletions
+1
View File
@@ -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: {
+11
View File
@@ -0,0 +1,11 @@
CREATE TABLE "ekklesia_core_user_preference" (
"user_id" text PRIMARY KEY NOT NULL,
"appearance" text,
"motion" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "ekklesia_core_user_preference_appearance_check" CHECK ("ekklesia_core_user_preference"."appearance" is null or "ekklesia_core_user_preference"."appearance" in ('system', 'light', 'dark')),
CONSTRAINT "ekklesia_core_user_preference_motion_check" CHECK ("ekklesia_core_user_preference"."motion" is null or "ekklesia_core_user_preference"."motion" in ('system', 'reduce', 'allow'))
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_user_preference" ADD CONSTRAINT "ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."ekklesia_core_user"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+6
View File
@@ -49,6 +49,12 @@
"destructive": {
"mode": "none"
}
},
{
"tag": "0008_user_preferences",
"destructive": {
"mode": "none"
}
}
]
}
+7
View File
@@ -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
}
]
}
+4
View File
@@ -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"
+13
View File
@@ -0,0 +1,13 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { Pool } from "pg";
import { accountSchema } from "./schema.js";
export type AccountDrizzleDatabase = NodePgDatabase<typeof accountSchema>;
/**
* A typed Drizzle view over an externally owned pool, exactly like `createDeviceDrizzleView`: it
* never constructs, registers an error listener on, or closes a pool.
*/
export function createAccountDrizzleView(pool: Pool): AccountDrizzleDatabase {
return drizzle(pool, { schema: accountSchema });
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./database.js";
export * from "./preferences.js";
export * from "./schema.js";
export * from "./store.js";
+108
View File
@@ -0,0 +1,108 @@
/**
* A person's own preferences (DESIGN.md §9.2, ADR 0062): how the Dashboard looks and behaves *for
* them*, on every device they sign in on.
*
* The model is deliberately a closed, typed record rather than a key/value bag or a JSON document.
* Every preference has a fixed name, a closed set of values, and a default; a value outside the set
* is refused at this boundary and again by the database's `CHECK` constraint. A new preference is a
* new field here and a new nullable column in a migration — never a second table, and never a
* free-form value a client could fill with anything.
*
* Core cannot import the design-token package that renders these, so the value sets are declared
* here and an `apps/api` test holds them equal to `@ekklesia/design-tokens`' own
* (`EKKLESIA_APPEARANCES`, `EKKLESIA_MOTION_PREFERENCES`): one list cannot change without the other.
*/
/** Light, dark, or following the operating system. `system` is the default. */
export const ACCOUNT_APPEARANCES = Object.freeze(["system", "light", "dark"] as const);
export type AccountAppearance = (typeof ACCOUNT_APPEARANCES)[number];
/** Following `prefers-reduced-motion`, always reducing, or always allowing the platform's subtle
* motion. `system` is the default. */
export const ACCOUNT_MOTION_PREFERENCES = Object.freeze(["system", "reduce", "allow"] as const);
export type AccountMotionPreference = (typeof ACCOUNT_MOTION_PREFERENCES)[number];
export interface AccountPreferences {
readonly appearance: AccountAppearance;
readonly motion: AccountMotionPreference;
}
/** What a person who has never chosen anything gets: follow the system for both. */
export const DEFAULT_ACCOUNT_PREFERENCES: AccountPreferences = Object.freeze({
appearance: "system",
motion: "system",
});
/** A change to some of a person's preferences. Fields left out keep their stored value, so
* changing motion on a phone never overwrites an appearance chosen a moment earlier on a laptop. */
export type AccountPreferencesUpdate = Partial<AccountPreferences>;
const PREFERENCE_VALUES: Readonly<Record<keyof AccountPreferences, readonly string[]>> =
Object.freeze({
appearance: ACCOUNT_APPEARANCES,
motion: ACCOUNT_MOTION_PREFERENCES,
});
/** The preference names, in a stable order. */
export const ACCOUNT_PREFERENCE_NAMES = Object.freeze(
Object.keys(PREFERENCE_VALUES).sort() as (keyof AccountPreferences)[],
);
export function isAccountAppearance(value: unknown): value is AccountAppearance {
return typeof value === "string" && ACCOUNT_APPEARANCES.includes(value as AccountAppearance);
}
export function isAccountMotionPreference(value: unknown): value is AccountMotionPreference {
return (
typeof value === "string" &&
ACCOUNT_MOTION_PREFERENCES.includes(value as AccountMotionPreference)
);
}
/**
* Reads an untrusted update strictly: a plain object naming at least one known preference, each
* with one of its allowed values, and nothing else. Returns `undefined` for anything else — an
* unknown key, a wrong value, an empty object, an array, `null` — so a malformed request is refused
* whole rather than partly applied, and no unexpected key can ever reach storage.
*/
export function parseAccountPreferencesUpdate(
value: unknown,
): AccountPreferencesUpdate | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return undefined;
}
if (Object.getPrototypeOf(value) !== Object.prototype) {
return undefined;
}
const entries = Object.entries(value);
if (entries.length === 0) {
return undefined;
}
const update: { appearance?: AccountAppearance; motion?: AccountMotionPreference } = {};
for (const [key, entry] of entries) {
if (key === "appearance" && isAccountAppearance(entry)) {
update.appearance = entry;
} else if (key === "motion" && isAccountMotionPreference(entry)) {
update.motion = entry;
} else {
return undefined;
}
}
return Object.freeze(update);
}
/** Resolves stored columns, where `null` means "never chosen", to the preferences in effect. A
* stored value the database constraint should have made impossible is treated as never chosen. */
export function resolveAccountPreferences(stored: {
readonly appearance: string | null;
readonly motion: string | null;
}): AccountPreferences {
return Object.freeze({
appearance: isAccountAppearance(stored.appearance)
? stored.appearance
: DEFAULT_ACCOUNT_PREFERENCES.appearance,
motion: isAccountMotionPreference(stored.motion)
? stored.motion
: DEFAULT_ACCOUNT_PREFERENCES.motion,
});
}
+56
View File
@@ -0,0 +1,56 @@
import { sql } from "drizzle-orm";
import { check, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { ekklesiaCoreUser } from "../identity/schema.js";
/**
* Core-owned storage for a person's own preferences (ADR 0062).
*
* **One row per person, one typed column per preference.** There is deliberately no key/value
* table and no JSON column: each preference has a fixed name and a closed set of values, enforced
* here by a `CHECK` constraint beneath the application-layer parser
* (`parseAccountPreferencesUpdate`), so nothing but a known value can be stored. A new preference is
* a new nullable column in a new migration.
*
* **`null` means "never chosen"**, and is resolved to the platform default when read
* (`resolveAccountPreferences`) rather than written as a column default. That keeps "this person
* chose System" distinguishable from "this person never chose", and lets a later preference column
* arrive without backfilling a value nobody chose — the same reasoning
* `ekklesia_core_module_state` follows for its absent rows (ADR 0028). A person with no row at all
* has chosen nothing.
*
* The row belongs to its person: `user_id` is the primary key and cascades on delete, so deleting
* an account removes its preferences with it, and no row can exist for a person who does not.
*
* Personal, not church-wide. Nothing here is branding, a church theme, or an administrator's
* decision for anyone else; those are Settings' (ADR 0061).
*/
export const ekklesiaCoreUserPreference = pgTable(
"ekklesia_core_user_preference",
{
userId: text("user_id")
.primaryKey()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
appearance: text("appearance"),
motion: text("motion"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
check(
"ekklesia_core_user_preference_appearance_check",
sql`${table.appearance} is null or ${table.appearance} in ('system', 'light', 'dark')`,
),
check(
"ekklesia_core_user_preference_motion_check",
sql`${table.motion} is null or ${table.motion} in ('system', 'reduce', 'allow')`,
),
],
);
/** Explicit map passed to Drizzle, mirroring `dashboardSchema` and `moduleLifecycleSchema`. The
* user table is included because the account summary reads a person's own name and email from it;
* nothing in this domain writes to it. */
export const accountSchema = {
user: ekklesiaCoreUser,
userPreference: ekklesiaCoreUserPreference,
} as const;
+156
View File
@@ -0,0 +1,156 @@
import type { EkklesiaUserId } from "@ekklesia/contracts";
import { eq, sql } from "drizzle-orm";
import { ekklesiaCoreUser } from "../identity/schema.js";
import type { AccountDrizzleDatabase } from "./database.js";
import {
type AccountPreferences,
type AccountPreferencesUpdate,
parseAccountPreferencesUpdate,
resolveAccountPreferences,
} from "./preferences.js";
import { ekklesiaCoreUserPreference } from "./schema.js";
export type AccountStoreErrorCode =
| "ACCOUNT_NOT_FOUND"
| "ACCOUNT_PREFERENCES_INVALID"
| "ACCOUNT_READ_FAILED"
| "ACCOUNT_WRITE_FAILED";
const accountStoreMessages: Readonly<Record<AccountStoreErrorCode, string>> = Object.freeze({
ACCOUNT_NOT_FOUND: "The account was not found.",
ACCOUNT_PREFERENCES_INVALID: "The preferences are not valid.",
ACCOUNT_READ_FAILED: "The account could not be read.",
ACCOUNT_WRITE_FAILED: "The preferences could not be saved.",
});
/** Never carries the underlying database error, SQL text, host, or stack — matching
* `DashboardStoreError` exactly. */
export class AccountStoreError extends Error {
readonly code: AccountStoreErrorCode;
constructor(code: AccountStoreErrorCode) {
super(accountStoreMessages[code]);
this.name = "AccountStoreError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* What a signed-in person is shown about their own account: the name and email they signed up
* with, and their preferences. Deliberately nothing else — not the image URL, not verification
* state, not timestamps — so the one place the browser learns about an account carries exactly
* what the account area displays (ADR 0062).
*/
export interface AccountSummary {
readonly name: string;
readonly email: string;
readonly preferences: AccountPreferences;
}
/**
* A person's own account, and only their own: every method takes the one user id whose account it
* reads or writes, and the HTTP surface supplies it from the authenticated session, never from the
* request. There is no method that lists accounts or reads someone else's by any other key.
*/
export interface AccountStore {
/** The person's summary, or `undefined` when no such person exists. A person who never chose a
* preference gets the defaults. */
readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined>;
/** Changes the named preferences and keeps the rest, returning everything now in effect. */
updatePreferences(
userId: EkklesiaUserId,
update: AccountPreferencesUpdate,
): Promise<AccountPreferences>;
}
function rethrowAsStoreError(error: unknown, code: AccountStoreErrorCode): never {
if (error instanceof AccountStoreError) {
throw error;
}
throw new AccountStoreError(code);
}
/**
* Core's persistence for a person's own account summary and preferences.
*
* Validation lives here as well as at the HTTP boundary, so the invariants hold for any caller: an
* update is re-read through the same strict parser, and a preference row can only be written for a
* person who exists.
*/
export function createAccountStore(db: AccountDrizzleDatabase): AccountStore {
return Object.freeze({
async readAccount(userId: EkklesiaUserId): Promise<AccountSummary | undefined> {
try {
const rows = await db
.select({
appearance: ekklesiaCoreUserPreference.appearance,
email: ekklesiaCoreUser.email,
motion: ekklesiaCoreUserPreference.motion,
name: ekklesiaCoreUser.name,
})
.from(ekklesiaCoreUser)
.leftJoin(
ekklesiaCoreUserPreference,
eq(ekklesiaCoreUserPreference.userId, ekklesiaCoreUser.id),
)
.where(eq(ekklesiaCoreUser.id, userId))
.limit(1);
const row = rows[0];
if (!row) {
return undefined;
}
return Object.freeze({
email: row.email,
name: row.name,
preferences: resolveAccountPreferences(row),
});
} catch (error) {
rethrowAsStoreError(error, "ACCOUNT_READ_FAILED");
}
},
async updatePreferences(
userId: EkklesiaUserId,
update: AccountPreferencesUpdate,
): Promise<AccountPreferences> {
const parsed = parseAccountPreferencesUpdate(update);
if (parsed === undefined) {
throw new AccountStoreError("ACCOUNT_PREFERENCES_INVALID");
}
try {
return await db.transaction(async (tx) => {
// Checked inside the transaction rather than left to the foreign key, so a person who
// does not exist is a clear "not found" instead of a constraint failure.
const people = await tx
.select({ id: ekklesiaCoreUser.id })
.from(ekklesiaCoreUser)
.where(eq(ekklesiaCoreUser.id, userId))
.limit(1);
if (people.length === 0) {
throw new AccountStoreError("ACCOUNT_NOT_FOUND");
}
const rows = await tx
.insert(ekklesiaCoreUserPreference)
.values({ userId, ...parsed })
.onConflictDoUpdate({
set: { ...parsed, updatedAt: sql`now()` },
target: ekklesiaCoreUserPreference.userId,
})
.returning({
appearance: ekklesiaCoreUserPreference.appearance,
motion: ekklesiaCoreUserPreference.motion,
});
const row = rows[0];
if (!row) {
throw new AccountStoreError("ACCOUNT_WRITE_FAILED");
}
return resolveAccountPreferences(row);
});
} catch (error) {
rethrowAsStoreError(error, "ACCOUNT_WRITE_FAILED");
}
},
});
}
+19
View File
@@ -169,6 +169,20 @@ export const CORE_MODULES_CONFIGURATION_MANAGE: PermissionId = parsePermissionId
*/
export const CORE_DEVICES_MANAGE: PermissionId = parsePermissionId("core.devices.manage");
/**
* 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,
}),
]);
/**
+178
View File
@@ -0,0 +1,178 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import {
ACCOUNT_APPEARANCES,
ACCOUNT_MOTION_PREFERENCES,
ACCOUNT_PREFERENCE_NAMES,
AccountStoreError,
createAccountStore,
DEFAULT_ACCOUNT_PREFERENCES,
ekklesiaCoreUserPreference,
parseAccountPreferencesUpdate,
resolveAccountPreferences,
} from "../src/account/index.js";
const migrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
describe("account preferences model", () => {
it("defaults both preferences to following the system", () => {
expect(DEFAULT_ACCOUNT_PREFERENCES).toEqual({ appearance: "system", motion: "system" });
expect(Object.isFrozen(DEFAULT_ACCOUNT_PREFERENCES)).toBe(true);
});
it("declares a closed, ordered set of names and values", () => {
expect(ACCOUNT_PREFERENCE_NAMES).toEqual(["appearance", "motion"]);
expect(ACCOUNT_APPEARANCES).toEqual(["system", "light", "dark"]);
expect(ACCOUNT_MOTION_PREFERENCES).toEqual(["system", "reduce", "allow"]);
});
it.each(
ACCOUNT_APPEARANCES.map((appearance) => [appearance] as const),
)("accepts appearance %s on its own", (appearance) => {
expect(parseAccountPreferencesUpdate({ appearance })).toEqual({ appearance });
});
it.each(
ACCOUNT_MOTION_PREFERENCES.map((motion) => [motion] as const),
)("accepts motion %s on its own", (motion) => {
expect(parseAccountPreferencesUpdate({ motion })).toEqual({ motion });
});
it("accepts both preferences together", () => {
expect(parseAccountPreferencesUpdate({ appearance: "dark", motion: "reduce" })).toEqual({
appearance: "dark",
motion: "reduce",
});
});
it.each([
["an empty object", {}],
["null", null],
["an array", [["appearance", "dark"]]],
["a string", "dark"],
["an unknown appearance", { appearance: "sepia" }],
["an unknown motion", { motion: "none" }],
["a differently cased value", { appearance: "Dark" }],
["a non-string value", { appearance: 1 }],
["a null value", { motion: null }],
["an unknown key beside a valid one", { appearance: "dark", radius: "large" }],
["an unknown key alone", { theme: "dark" }],
["a user id", { appearance: "dark", userId: "someone-else" }],
["a prototype key", JSON.parse('{"__proto__": {"appearance": "dark"}}')],
[
"a class instance",
new (class Preferences {
appearance = "dark";
})(),
],
])("refuses %s whole", (_label, value) => {
expect(parseAccountPreferencesUpdate(value)).toBeUndefined();
});
it("resolves never-chosen columns to the defaults, and keeps a chosen value", () => {
expect(resolveAccountPreferences({ appearance: null, motion: null })).toEqual(
DEFAULT_ACCOUNT_PREFERENCES,
);
expect(resolveAccountPreferences({ appearance: "dark", motion: null })).toEqual({
appearance: "dark",
motion: "system",
});
// A value the constraint should have made impossible is never passed through.
expect(resolveAccountPreferences({ appearance: "neon", motion: "allow" })).toEqual({
appearance: "system",
motion: "allow",
});
});
});
describe("account preference storage", () => {
it("is one row per person, keyed and cascaded by user id, with one typed column per preference", () => {
const config = getTableConfig(ekklesiaCoreUserPreference);
expect(config.name).toBe("ekklesia_core_user_preference");
expect(config.columns.map((column) => column.name).sort()).toEqual([
"appearance",
"created_at",
"motion",
"updated_at",
"user_id",
]);
const userId = config.columns.find((column) => column.name === "user_id");
expect(userId?.primary).toBe(true);
// Preference columns are nullable: `null` is "never chosen", resolved to the default on read.
for (const name of ACCOUNT_PREFERENCE_NAMES) {
expect(config.columns.find((column) => column.name === name)?.notNull).toBe(false);
}
expect(config.foreignKeys).toHaveLength(1);
expect(config.foreignKeys[0]?.onDelete).toBe("cascade");
});
it("constrains each column in the migration to exactly the model's values", async () => {
const sql = await readFile(path.join(migrationsDirectory, "0008_user_preferences.sql"), "utf8");
const allowed = (column: string): string[] => {
const match = new RegExp(`"${column}" in \\(([^)]*)\\)`, "u").exec(sql);
return (match?.[1] ?? "").split(",").map((value) => value.trim().replaceAll("'", ""));
};
expect(allowed("appearance")).toEqual([...ACCOUNT_APPEARANCES]);
expect(allowed("motion")).toEqual([...ACCOUNT_MOTION_PREFERENCES]);
// No JSON, no key/value pair, nothing a client could fill with anything.
expect(sql).not.toMatch(/jsonb?|hstore/iu);
});
it("is a migration that destroys nothing", async () => {
const metadata = JSON.parse(
await readFile(path.join(migrationsDirectory, "meta", "_ekklesia.json"), "utf8"),
) as { entries: { tag: string; destructive: { mode: string } }[] };
expect(metadata.entries.find((entry) => entry.tag === "0008_user_preferences")).toEqual({
destructive: { mode: "none" },
tag: "0008_user_preferences",
});
});
});
describe("account store validation", () => {
it("refuses an invalid update before touching the database", async () => {
const untouchable = new Proxy(
{},
{
get() {
throw new Error("the database must not be reached");
},
},
);
const store = createAccountStore(untouchable as never);
await expect(
store.updatePreferences("user-1" as never, { appearance: "sepia" } as never),
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID", name: "AccountStoreError" });
await expect(store.updatePreferences("user-1" as never, {})).rejects.toBeInstanceOf(
AccountStoreError,
);
});
it("reports a database failure without the underlying error", async () => {
const failing = {
select() {
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
},
transaction() {
throw new Error("connect ECONNREFUSED postgres://secret@db:5432");
},
};
const store = createAccountStore(failing as never);
const read = await store.readAccount("user-1" as never).catch((error: unknown) => error);
expect(read).toBeInstanceOf(AccountStoreError);
expect(read).toMatchObject({ code: "ACCOUNT_READ_FAILED" });
expect(String((read as Error).stack)).not.toContain("secret");
const write = await store
.updatePreferences("user-1" as never, { appearance: "dark" })
.catch((error: unknown) => error);
expect(write).toMatchObject({ code: "ACCOUNT_WRITE_FAILED" });
expect(String((write as Error).message)).not.toContain("ECONNREFUSED");
});
});
+378
View File
@@ -0,0 +1,378 @@
import { randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { EkklesiaUserId } from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import {
type AccountStore,
AccountStoreError,
createAccountDrizzleView,
createAccountStore,
} from "../src/account/index.js";
import { createValidatedPostgresPool } from "../src/database/index.js";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "../src/migrations/index.js";
/**
* A person's own account summary and preferences against real PostgreSQL (ADR 0062).
*
* The unit suite proves the model and parser; this proves what only a database can: that the
* migration creates the table with its constraints, that a choice survives a completely new
* connection (which is what "follows you to another device" rests on), that one person's change
* never touches another's row, that the database itself refuses a value outside the model, and
* that deleting a person deletes their preferences.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"drizzle",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The account integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The account integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 14);
databaseCounter += 1;
return `ekk_acct_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure stays static when connection cleanup also fails.
}
throw harnessError("The account integration database connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-acct-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"core:0003_dashboard_navigation_tables",
"core:0004_module_lifecycle_state",
"core:0005_module_configuration",
"core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"core:0008_user_preferences",
]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { force: true, recursive: true });
}
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
});
const ada = "user-ada" as EkklesiaUserId;
const grace = "user-grace" as EkklesiaUserId;
const nobody = "user-nobody" as EkklesiaUserId;
async function insertPerson(
client: Client,
id: string,
name: string,
email: string,
): Promise<void> {
await safeQuery(
client,
"insert into ekklesia_core_user (id, name, email, email_verified) values ($1, $2, $3, true)",
[id, name, email],
);
}
describe.sequential("live Core account store", () => {
let database: { name: string; url: string };
let admin: Client;
let store: AccountStore;
let close: () => Promise<void>;
it("creates the preference table with its constraints", async () => {
database = await createDisposableDatabase("fresh");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
const constraints = await safeQuery<{ constraint_name: string; constraint_type: string }>(
admin,
`select constraint_name, constraint_type from information_schema.table_constraints
where table_name = 'ekklesia_core_user_preference' and constraint_type <> 'CHECK'
or constraint_name like 'ekklesia_core_user_preference_%_check'
order by constraint_name`,
);
const deleteRules = await safeQuery<{ delete_rule: string }>(
admin,
`select rc.delete_rule from information_schema.referential_constraints rc
join information_schema.table_constraints tc on tc.constraint_name = rc.constraint_name
where tc.table_name = 'ekklesia_core_user_preference'`,
);
expect(constraints.map((constraint) => constraint.constraint_name)).toEqual([
"ekklesia_core_user_preference_appearance_check",
"ekklesia_core_user_preference_motion_check",
"ekklesia_core_user_preference_pkey",
"ekklesia_core_user_preference_user_id_ekklesia_core_user_id_fk",
]);
expect(deleteRules.map((rule) => rule.delete_rule)).toEqual(["CASCADE"]);
await insertPerson(admin, ada, "Ada Lovelace", "[email protected]");
await insertPerson(admin, grace, "Grace Hopper", "[email protected]");
const handle = createValidatedPostgresPool(database.url);
close = handle.close;
store = createAccountStore(createAccountDrizzleView(handle.pool));
});
it("reads a person who never chose anything with their name, email, and the defaults", async () => {
expect(await store.readAccount(ada)).toEqual({
email: "[email protected]",
name: "Ada Lovelace",
preferences: { appearance: "system", motion: "system" },
});
const rows = await safeQuery(admin, "select * from ekklesia_core_user_preference");
// Reading never materializes a row nobody chose.
expect(rows).toHaveLength(0);
});
it("answers undefined for a person who does not exist", async () => {
expect(await store.readAccount(nobody)).toBeUndefined();
});
it.each([
["appearance", "light"],
["appearance", "dark"],
["appearance", "system"],
["motion", "reduce"],
["motion", "allow"],
["motion", "system"],
] as const)("saves %s %s and reads it back", async (name, value) => {
const saved = await store.updatePreferences(ada, { [name]: value });
expect(saved[name]).toBe(value);
expect((await store.readAccount(ada))?.preferences[name]).toBe(value);
});
it("changes only the preference named, keeping the other", async () => {
await store.updatePreferences(ada, { appearance: "dark", motion: "reduce" });
expect(await store.updatePreferences(ada, { motion: "allow" })).toEqual({
appearance: "dark",
motion: "allow",
});
expect(await store.updatePreferences(ada, { appearance: "light" })).toEqual({
appearance: "light",
motion: "allow",
});
});
it("stores a first partial choice with the other preference still never chosen", async () => {
expect(await store.updatePreferences(grace, { motion: "reduce" })).toEqual({
appearance: "system",
motion: "reduce",
});
const rows = await safeQuery<{ appearance: string | null; motion: string | null }>(
admin,
"select appearance, motion from ekklesia_core_user_preference where user_id = $1",
[grace],
);
expect(rows).toEqual([{ appearance: null, motion: "reduce" }]);
});
it("keeps each person's preferences to themselves", async () => {
await store.updatePreferences(ada, { appearance: "dark", motion: "allow" });
await store.updatePreferences(grace, { appearance: "light", motion: "reduce" });
expect((await store.readAccount(ada))?.preferences).toEqual({
appearance: "dark",
motion: "allow",
});
expect(await store.readAccount(grace)).toEqual({
email: "[email protected]",
name: "Grace Hopper",
preferences: { appearance: "light", motion: "reduce" },
});
});
it("survives a completely new connection, as another device signing in would see", async () => {
const reopened = createValidatedPostgresPool(database.url);
try {
const other = createAccountStore(createAccountDrizzleView(reopened.pool));
expect((await other.readAccount(ada))?.preferences).toEqual({
appearance: "dark",
motion: "allow",
});
} finally {
await reopened.close();
}
});
it("refuses to store preferences for a person who does not exist", async () => {
const failure = await store
.updatePreferences(nobody, { appearance: "dark" })
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(AccountStoreError);
expect(failure).toMatchObject({ code: "ACCOUNT_NOT_FOUND" });
const rows = await safeQuery(
admin,
"select 1 from ekklesia_core_user_preference where user_id = $1",
[nobody],
);
expect(rows).toHaveLength(0);
});
it("refuses an invalid update with nothing written", async () => {
await expect(
store.updatePreferences(ada, { appearance: "sepia" } as never),
).rejects.toMatchObject({ code: "ACCOUNT_PREFERENCES_INVALID" });
expect((await store.readAccount(ada))?.preferences.appearance).toBe("dark");
});
it("has the database itself refuse a value outside the model", async () => {
await expect(
admin.query(
"update ekklesia_core_user_preference set appearance = 'sepia' where user_id = $1",
[ada],
),
).rejects.toMatchObject({ code: "23514" });
await expect(
admin.query("update ekklesia_core_user_preference set motion = 'none' where user_id = $1", [
ada,
]),
).rejects.toMatchObject({ code: "23514" });
});
it("deletes a person's preferences with the person", async () => {
await safeQuery(admin, "delete from ekklesia_core_user where id = $1", [grace]);
const rows = await safeQuery(
admin,
"select 1 from ekklesia_core_user_preference where user_id = $1",
[grace],
);
expect(rows).toHaveLength(0);
expect(await store.readAccount(grace)).toBeUndefined();
await safeEnd(admin);
await close();
});
});
+14 -1
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
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 {
+1 -1
View File
@@ -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 {
+5 -2
View File
@@ -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",