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
+61 -13
View File
@@ -79,7 +79,8 @@ No real values are shown here — configure per environment and never commit the
| `POST\|GET /api/auth/*` | Better Auth (mounted directly) | Supported Better Auth routes retain their native shapes except for the successful email sign-in normalization above. The four denied paths above never reach Better Auth. See [Better Auth's email/password docs](https://www.better-auth.com/docs/authentication/email-password) for supported endpoints such as `/sign-up/email` and `/sign-out`. |
| `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":"ada@example.test","preferences":{"appearance":"dark","motion":"system"}}` |
| `PATCH /api/v1/account/preferences` | `{"appearance":"light"}`, `{"motion":"reduce"}`, or both | `{"appearance":"light","motion":"system"}`, everything now in effect |
- **Self-only by construction.** Neither route accepts a user id in the path, query, or body. The
account is always the session's, so no request can read or change anyone else's.
- **Values.** `appearance` is one of `system`, `light`, or `dark`. `motion` is one of `system`,
`reduce`, or `allow`. A person who never chose gets `system` for both.
- **`PATCH` is partial.** It changes only the preferences it names and keeps the rest, so two devices
changing different preferences never overwrite each other. A body that is empty, names any other key,
or uses any other value is refused whole with `400 {"code":"INVALID_REQUEST"}`, and nothing is
written.
- **What it returns.** Name and email exactly as the account holds them, and nothing else about the
account: no image URL, verification state, timestamps, or user id.
- **Authority.** Both routes require `core.account.self.manage`, whose baseline is `authenticated`, so
every signed-in person may use them with no grant. An anonymous caller or a paired device gets
`403 {"code":"AUTHORIZATION_DENIED"}`. The `PATCH` also needs an exact trusted `Origin`
(`403 {"code":"UNTRUSTED_ORIGIN"}`) and a body within the Core mutation limit (`413`), both checked
before authorization.
- **Failures.** A person whose session outlived their account gets `404 {"code":"ACCOUNT_NOT_FOUND"}`.
A storage failure is a sanitized `503` (`ACCOUNT_READ_FAILED` or `ACCOUNT_WRITE_FAILED`).
- **Headers.** Responses are `Cache-Control: no-store` and `Vary: Origin, Cookie`.
**Storage.** One row per person in `ekklesia_core_user_preference`, with one typed, `CHECK`-constrained
column per preference. `null` means never chosen, and deleting the person deletes the row. It is created
by migration `core:0008_user_preferences`, which an operator applies with `pnpm migrations:apply`. Until
it is applied, the account routes answer a sanitized `503`, and the rest of the Dashboard keeps working.
**In the browser.** The Dashboard loads the account at bootstrap and applies its preferences through
the ADR 0060 theme controller. It keeps a two-word copy (`ekklesia.dashboard.appearance` in
`localStorage`) for one purpose: so `appearance.js` can apply the choice before the first paint. That
copy is never the source of truth. It is replaced by the account's answer on every load, cleared on
sign-out, and followed by other open tabs through the browser's `storage` event.
## Module UI inventory
```text
+10 -1
View File
@@ -112,6 +112,8 @@ list:
| `core.dashboard.navigation.manage` | `restricted` | Organize the Dashboard: create, rename, order, and delete navigation groups. |
| `core.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
+4 -2
View File
@@ -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
+22
View File
@@ -425,6 +425,28 @@ enforced by `requirePermission` against the composed platform permission registr
enforcement neither knows nor cares what a browser decided. Hiding a control is a usability
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
+30 -3
View File
@@ -404,9 +404,36 @@ no runtime assertion can make: that the identity feature names no SMTP detail, t
schema gained a token column and no identity code hashes or compares a token, that nothing
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: