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
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:
co-authored by
Claude Opus 5.5
parent
8571e6854c
commit
cd0287537e
@@ -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":"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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user