diff --git a/.dockerignore b/.dockerignore
index b5ac416..b3c82df 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,4 +1,5 @@
.git
+# Load-bearing: a stray copy would bake in at /app/node_modules and shadow /node_modules.
node_modules
npm-debug.log
*.log
diff --git a/AGENTS.md b/AGENTS.md
index b94f4fb..c2a6176 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,322 +3,270 @@
Guidance for AI agents and contributors working in this repo. Read `README.md` for
commands and layout.
+## Maintaining this file
+
+Every agent session reads this file in full, so its length is a cost paid on every task.
+Keep it the shortest thing that still changes what someone does.
+
+- **Trim as you add.** After any edit, re-read the whole file and compress: merge overlapping
+ entries, cut prose that restates a rule, drop what the code or `README.md` already says.
+ Question each section — same information, fewer words.
+- **Record the decision and the reason it turns on, nothing else.** Not the investigation, not
+ what was tried first, not how it was verified — that belongs in the PR that made the change.
+- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
+ holding.
+- **One home per fact.** Link to it rather than restating it — the same sentence in five files
+ is five things to update and five chances to drift.
+
## How to work with tasks
Use the file `todo.md`.
-For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in `todo.md`. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
+For each todo item, interview the user extensively to deeply understand the scope and goal of
+each. When done, check the completed task in `todo.md`. Commit all changes and push to a new
+branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode)
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
-2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`,
- `@larvit/log` — the last itself zero-dependency, for structured/OTLP logging).
- Prefer the Node standard library; justify any new dependency; do not add
- frameworks. The app is
- **stateless — no database**. Auth/identity/OAuth are **Ory sidecar services**
- (Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with
- built-in `fetch` — no SDK dependency. New capabilities ship as **plugin
- folders** under `plugins/` that fetch their data from upstream services, not as
- core code. See `README.md` for the architecture.
+2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`).
+ Prefer the Node standard library; justify any new dependency; do not add frameworks. The app is
+ **stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** (Kratos/Keto/Hydra,
+ backed by Postgres), reached over their REST APIs with built-in `fetch` — no SDK. New
+ capabilities ship as **plugin folders** under `plugins/` that fetch their data from upstream
+ services, not as core code.
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
- `exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer
- exact types and limit nullable and multi option types when possible. KISS.
-4. **Environment-agnostic** — the app never asks *which environment* it runs in; there is
- no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config
- toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"),
- read once in `src/config.ts`. Compose files set the toggles per deployment.
-5. **Semantic, accessible DOM** — markup is a first-class concern. Use the right element
- for the job (landmarks, one `
` per page + sane heading order, lists, ` ` with
- row/column headers, ``/``, `` vs ``); add ARIA only to fill
- real gaps (`aria-current`, `aria-sort`, labels). Classes/ids name *meaning*, not looks.
- Prefer native semantics over `div` + ARIA. New views and partials keep this bar.
-6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route)
- has a Playwright E2E test, and a new surface ships *with* its E2E in the same change.
- Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it
- that way as it grows (never serialise on shared state); parallelism is what keeps it
- fast. E2E runs in Docker against the live stack — see `README.md`.
-7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the
- only way to add domain features. It optimises for being **powerful, predictable, and
- overloadable** (a plugin can take over as much of a page as it wants), and the host
- **fails loud at boot/discovery** (bad manifest, version mismatch, or conflict stops
- startup with a clear message) rather than sandboxing at runtime. Runtime crash-isolation
- is a deliberate **non-goal** — diagnose at deploy time, not in production. Keep this
- contract stable; see `README.md` → Building plugins.
+ `exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
+ limit nullable and multi-option types.
+4. **Environment-agnostic** — the app never asks *which environment* it runs in; no `NODE_ENV`
+ branching. Every behaviour is an **explicit config toggle** (e.g. `CACHE_TEMPLATES`,
+ `REQUIRE_SECURE_SECRETS`), read once in `src/config.ts`. Compose files set them per deployment.
+5. **Semantic, accessible DOM** — use the right element for the job (landmarks, one `` per page
+ + sane heading order, lists, ` ` with row/column headers, ``/``,
+ `` vs ``); add ARIA only to fill real gaps (`aria-current`, `aria-sort`, labels).
+ Classes/ids name *meaning*, not looks. Prefer native semantics over `div` + ARIA. New views and
+ partials keep this bar.
+6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route) has a
+ Playwright E2E test, shipped in the same change as the surface. Tests stay independent and
+ side-effect-free so the suite runs `fullyParallel` — never serialise on shared state.
+7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way
+ to add domain features. It optimises for being **powerful, predictable, and overloadable** (a
+ plugin can take over as much of a page as it wants), and the host **fails loud at boot/discovery**
+ (bad manifest, version mismatch, conflict) rather than sandboxing at runtime. Runtime
+ crash-isolation is a deliberate **non-goal** — diagnose at deploy time, not in production.
## Deliberate architectural deviations (don't re-flag)
-Intentional, reasoned choices — an architecture review should honor them, not re-raise
-them. Revisit only if the stated reason stops holding.
+Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
+Revisit only if the stated reason stops holding.
-- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
- (session-JWT hot path, guards, and the Ory REST clients), `i18n/` (locale resolution + the
- catalogs, `locales/` holding the data), `plugin-host/`
- (discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the
- `ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome);
- `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
- are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
- concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
- screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`),
- not `src/` code.
-- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
- base request context. It protects the I/O-free hot path on the public, bot-hit landing
- (`/`). (Declined twice.)
-- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web`
- never touches SMTP. Customization is Kratos' built-in `courier.template_override_path`,
- not app code — keeping `web` stateless and dependency-light (see [Email](README.md#email)).
-- **Plugins and config import the host only via package.json `imports`** — `#plugin-api`
- → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts` — never a
- relative `../../src/*` path. These two barrels are the whole author/operator contract
- surface; the `src/*` behind them may be refactored freely. Depth-independent and
- refactor-stable by design — don't "fix" a `#`-import back to a relative path.
- **One caveat:** `#plugin-api` re-exports the Ory client types for the `ctx.system` surface
- (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their DTOs and error classes). Those shapes are
- therefore **contract-visible** — changing them is a plugin-API break needing a major
- `apiVersion` bump, not a free refactor. Keep the Ory clients stable, or bump the version.
-- **A plugin/config folder must stay a plain folder — no `package.json` of its own.** Node
- resolves `#`-specifiers against the nearest parent `package.json`; a `package.json` inside
- the folder becomes its own scope and `#plugin-api`/`#menu-config` stop resolving. Accepted
- cost of the `#`-import contract (fits the stateless, no-per-plugin-deps ethos). A plugin
- kept in its own repo typechecks against the barrel only when mounted under the host tree
- (or by adding a local `imports` map / vendored stub).
-- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins//` copies to
- `plugins//`, `examples/config/menu.ts` to `config/menu.ts`. Both mirror folders are in
- `tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
- in place *and* copies across unchanged. Never commit real plugins/config into the root
- mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
-- **Authorization vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships
- no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC,
- where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route
- gates on one operation, so it gates on a permission, and a bundle is just a group with several
- grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
- separate per-row tier.
-- **A stricter manifest rule breaks already-copied plugins, and while `HOST_API_VERSION` is frozen the
- failure names a symptom rather than the cause.** `plugins/` is an operator-owned drop-in mount that
- ships empty, so no test ever sees a *stale* copy — an operator's is whatever version they took. On
- 2026-08-05 the `:` rule stopped a pre-existing `plugins/admin` at boot with
- "route gates on admin", which reads as the operator's bug rather than an out-of-date copy.
- **Accepted during development** (maintainer, 2026-08-05): `checkApiVersion` is already the right
- mechanism — a breaking manifest change bumps the major and a stale plugin is refused by *version*,
- which says plainly what happened. That only starts working once the freeze lifts, so until then a
- stricter rule ships with a README → Upgrading entry and the discovery error carries the re-copy
- hint. **Valid while `HOST_API_VERSION` stays frozen at 1.0.0** — when the first external plugin
- lifts it (see the Rules section), the version check takes over and this note can go.
- Fail-loud stays right either way: the alternative is a route gating on a name nobody can be
- granted, i.e. a permanent silent 403.
+### Structure & contracts
+
+- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/` (session-JWT hot
+ path, guards, Ory REST clients), `i18n/` (locale resolution + catalogs), `plugin-host/`
+ (discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts` behind
+ `ctx.system`), `ui/` (design-system view-models + menu/chrome). `server.ts`/`config.ts`/`logger.ts`
+ and the topology-guard `*.test.ts` stay at the root; tests are co-located. Add a new module to the
+ folder owning its concern; don't reintroduce a flat tree. The core ships **no domain screens** —
+ even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
+- **Plugins and config import the host only via package.json `imports`** — `#plugin-api` →
+ `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`, never a relative
+ `../../src/*` path. These two barrels are the whole contract surface; the `src/*` behind them may
+ be refactored freely. Don't "fix" a `#`-import back to a relative path. Two consequences:
+ - `#plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
+ DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
+ `apiVersion` bump, not a free refactor.
+ - **A plugin/config folder must stay a plain folder — no `package.json` of its own**, which would
+ become its own scope and stop `#`-specifiers resolving. A plugin kept in its own repo therefore
+ typechecks against the barrel only when mounted under the host tree (or with a vendored stub).
+- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins//` copies to `plugins//`,
+ `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve
+ the host via `#`-imports, so each typechecks in place *and* copies across unchanged. Never commit
+ real plugins/config into the root mount dirs — they ship empty.
+- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request
+ context. It protects the I/O-free hot path on the public, bot-hit landing (`/`). (Declined twice.)
+- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
+ `dashboard`) and an `onRequest` short-circuit build their context with `contextFor(pluginId)`
+ exactly as a plugin route does — otherwise `ctx.t` is the core translator and the plugin's own keys
+ render as bare keys on the pages it owns.
+- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
+ touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code — keeping
+ `web` stateless and dependency-light.
+
+### Authorization
+
+- **Vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships no namespaces —
+ all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
+ a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
+ "permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
- **A permission name is always `:`** — `scheduling:read`, `users:write`. A bare
- word names *who someone is* — a role — and roles are groups here; the old catch-all `admin`
- permission was exactly that mistake, split into `users:`/`groups:`/`permissions:`/`oauth2-clients:`
- × `read`/`write` 2026-08-05. **Enforced at discovery** (`isValidPermissionName` in
- `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every
- declared name), fail-loud like every other manifest rule — not only in the admin GUI, which an
- operator removes by not copying the example in. Decisions around it:
- - **Names are authored in plugin code; only grants live in Keto.** The host collects every
- installed plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`),
- and that catalog *is* the fixed list the admin screens offer. So there is **no Permissions admin
- screen**: nothing in a GUI invents a name, and holding one is a property of a user or a group,
- edited as a checkbox list on those two screens. A tuple in Keto naming something no installed
- plugin declares gates nothing and is not offered — and a save never revokes it, since the picker
- only speaks for what it showed. Decided with the maintainer 2026-08-05, replacing the CRUD
- Permissions screen.
- - `` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`). Deliberate
- cross-plugin sharing is a goal, so the pre-2026-08-05 `:` guidance was wrong: users
- are the *host's*, not the admin plugin's. Cost: collision-freedom became a convention rather than
- structural. Accepted — the alternative penalizes the sharing case.
- - **Declaring a permission stays optional.** Requiring every gated route to declare its permission
- would make `findConflicts` see all overlaps, but would then warn on exactly the legitimate
- sharing case above. Shape is enforced; declaration is not.
- - **There is no name-minting path in the GUI at all**, which is what makes the discovery check the
- whole story: the only way a name comes into being is a plugin declaring it, and discovery refuses
- a badly-shaped declaration at boot. An earlier revision of this branch enforced the rule in the
- Permissions screen's create form instead and needed a second guard for the assign form, which
- could also mint one — deleting the screen removed both.
- - `ADMIN_PERMISSIONS` **defaults to empty**: every permission is owned by the plugin that gates on
- it, and a host-invented default would gate nothing. **An unusable value there is dropped with a
- warning, never fatal** — fail-loud belongs at the manifest boundary, where a developer authored
- the mistake; `bootstrap` gates `web`, so refusing operator env takes the whole stack down. This
- is not hypothetical: `admin` was this setting's own default until 2026-08-05, so a boot-breaking
- value is the *expected* leftover on upgrade, and a revision of this branch shipped exactly that
- bug past a green CI. `e2e-tests/compose.auth.yml` now seeds `ADMIN_PERMISSIONS: admin,users:read`
- so the container proves it survives one; verified by negative control (re-adding the throw fails
- that suite at stack-up). This makes the seed a function of what
- `bootstrap` discovers, and a plugin dropped in after first boot therefore needs
- `docker compose up -d` (which re-runs the one-shot), not `restart web`. The base file gives
- `bootstrap` and `web` the same baked `plugins/`; only `compose.override.yml`'s dev-only `.:/app`
- makes `web` diverge onto the host tree, so the matching `./plugins` mount for `bootstrap` lives
- **there and only there** — in the base file it would desynchronise prod and collide with the e2e
- stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a read-only
- parent is EROFS and the container never starts). Valid while bootstrap is the only writer of
- grants.
+ word names *who someone is* (a role), and roles are groups here; the old catch-all `admin` was
+ exactly that mistake. **Enforced at discovery** (`isValidPermissionName` in `plugin-host/plugin.ts`,
+ checked by `shapeError` over every route/nav `permission` and every declared name), fail-loud like
+ any other manifest rule — not only in the admin GUI, which an operator removes by not copying it in.
+ - **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
+ plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`), and
+ that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
+ in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox
+ list there. A Keto tuple naming something no installed plugin declares gates nothing, is not
+ offered, and is never revoked by an unrelated save — the picker only speaks for what it showed.
+ - `` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are
+ the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather
+ than structural. Accepted — the alternative penalizes the sharing case.
+ - **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
+ overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
+ declaration is not.
+ - `ADMIN_PERMISSIONS` **defaults to empty** (every permission is owned by the plugin gating on it),
+ and **an unusable value is dropped with a warning, never fatal** — fail-loud belongs at the
+ manifest boundary where a developer authored the mistake, whereas `bootstrap` gates `web`, so
+ refusing operator env takes the whole stack down. `e2e-tests/compose.auth.yml` seeds a bad value
+ so the container proves it survives one. The seed is a function of what `bootstrap` discovers, so
+ a plugin dropped in after first boot needs `docker compose up -d` (re-runs the one-shot), not
+ `restart web`. `bootstrap`'s matching `./plugins` mount belongs in `compose.override.yml` and
+ nowhere else: in the base file it would desynchronise prod and collide with the e2e stacks, which
+ bind individual plugins *inside* `/app/plugins` (a nested mount into a read-only parent is EROFS
+ and the container never starts). Valid while bootstrap is the only writer of grants.
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin
- example it buys one thing: the route table and the in-handler guard derive from one function, so
- 29 routes × 2 gate sites cannot drift. As a general mechanism it would make authorization a
- function of the transport verb, and a route table must answer "what does this need?" on its own.
-- **A `:read`-only holder must never be shown a write affordance.** The split created a real read-only
- operator (a helpdesk account with `users:read`), and the host's 403 is the backstop, not the UX: the
- list/detail models carry `canWrite` and the views drop create/save/delete/add/remove, while the
- permission picker still renders — disabled — because *seeing* who holds what is the point of `:read`.
- A *write-intent GET* — a create form or a delete-confirm page — is the exception to
- `actionForMethod`: it gates on `:write` (declared in the route table and passed to the handler's
- guard, so the two still agree), because a page whose only purpose is to start a write should refuse
- a reader rather than render a form whose submit 403s.
- Two grant-specific guards go with it, both restoring behaviour the deleted Permissions screen had:
- you cannot revoke your own **direct** grants on the Users screen (self-lockout would need a `curl`
- against Keto to undo, which the operator persona can't do — same shape as the self-deactivate/
- self-delete guards), and a permission held *through a group* renders ticked-but-disabled rather than
- unticked, because showing it unticked stated the opposite of the truth and unticking it wrote
- nothing while looking like a successful revoke. **Known gap, same scope the deleted screen had:**
- the group paths are unguarded — unticking a permission on a group you belong to, removing yourself
- from it, or deleting it can all still strip your own effective access. The robust "last effective
- holder" check needs a reverse Keto query and is deferred. Raised by the architecture + product +
- stability reviews 2026-08-05.
-- **`users:write` and `groups:write` are equivalent to full administrative access**, and the split
- does not change that: `groups:write` adds you to any group, including one holding every permission;
- `users:write` mints a recovery code for any account. The containment the split buys is real on the
- **read** half only (`users:read` is a safe helpdesk grant). Don't let the per-resource naming imply
- otherwise in docs. Raised by the architecture review 2026-08-05.
-- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
- an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
- "accounts" — so this is house style, not a renamed concept, and "user" is the word readers
- already know (Nielsen's heuristic #2: match between the system and the real world). One note in
- README → Auth records the mapping so nobody has to rediscover it. The single exception is the
- `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's
- name — don't rename that one.
+ example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
+ × 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
+ verb — a route table must answer "what does this need?" on its own.
+- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
+ `canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
+ disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
+ form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`, since a
+ page whose only purpose is to start a write should refuse a reader rather than render a form whose
+ submit 403s. Two grant-specific guards go with it: you cannot revoke your own **direct** grants
+ (self-lockout would need a `curl` against Keto to undo), and a permission held *through a group*
+ renders ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the
+ group paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting
+ it can still strip your own access. The robust "last effective holder" check needs a reverse Keto
+ query and is deferred.
+- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
+ adds you to any group, including one holding every permission; `users:write` mints a recovery code
+ for any account. The containment the split buys is real on the **read** half only (`users:read` is
+ a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
+- **Plainpages says "user" everywhere; Ory's word is "identity".** Ory's own docs use the terms
+ interchangeably, so this is house style, not a renamed concept. The single exception is the
+ `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape — don't rename it.
+
+### i18n
+
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`,
- and when the URL asked for one the host carries it onto the links it renders (`ctx.localeHref`).
- A cookie would make a page's language invisible in its address and unshareable; the cost is that a
- plugin must wrap its own hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a
- lone language from `Accept-Language` takes the first regional catalog for it. Decided 2026-08-03.
-- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
- — keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
- and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
- locales than the host (its strings fall back to `en-US` per key), never one the host lacks.
+ and when the URL asked for one the host carries it onto the links it renders. A cookie would make a
+ page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
+ hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
+ `Accept-Language` takes the first regional catalog for it.
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
`pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
- href they render in `localeHref`; the nav and the sign-in link are wrapped upstream in `chrome.ts`;
- and the two GET forms
- (filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the
- whole query string and no href wrapper can reach it. Putting the obligation on each call site was
- tried first and missed five of eight sites inside one commit — including the admin screens.
- `ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links).
- **A form's `action` counts as a link** — a POST replaces the URL as completely as a GET submit, so
- the sign-out, consent and auth-card forms carry it too; without that, picking a language and then
- saving anything drops back to `Accept-Language`. The one round-trip that cannot carry it is the
- Kratos sign-in POST, whose action is an absolute off-site URL.
- Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
- so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
-- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
- so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
- `localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
- so a collision loses the key instead of breaking the shell.
-- **The language picker is on every page, POST-rendered ones included.** Maintainer's call
- 2026-08-04, overriding an earlier decision to hide it there. The problem it was hiding is real: a
- POST-rendered URL frequently answers no GET (`POST /admin/users/:id/recovery`), so a link back to
- it dead-ends on a 405. The host therefore resolves the picker's target (`app.ts` → `switchBase`):
- this path when it answers GET, else the same-origin Referer, else `/`. Accepted cost: switching
- language on such a page leaves that POST's own result behind (a re-rendered form's input, or a
- one-time recovery code). Valid while the picker is expected on literally every page — if that ever
- softens, hiding it after a POST is the simpler answer.
-- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
- `dashboard`) and an `onRequest` short-circuit dispatch a plugin's handler, so they build the
- context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core
- translator and the plugin's own keys render as bare keys on the pages it owns. Found by review
- 2026-08-03 after all three paths shipped with the host's context.
-- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`** — `locales/.ts`
- for the core and `locales/plugins//.ts` for an installed plugin, each adding a language or
- replacing that tag's catalog wholesale. Adding a language must never require forking the image or a
- vendored plugin folder. The SHIPPED `en-US` (core's, or the plugin's own) stays the parity baseline
- even when the mount replaces it, so a mounted catalog is checked rather than trusted (one compared
- only against itself would boot green with the whole UI rendering keys), and each half is reported
- under the folder it actually lives in.
-- **RTL is out of scope until there is a real use case.** `textDirection` sets `` from the
- locale's script because that is free and correct, but the stylesheet keeps physical `left`/`right`
- properties — a genuine RTL locale needs those moved to logical ones first. Don't convert the CSS or
- file findings about it on spec. Maintainer's call 2026-08-04; valid while no deployment needs an
- RTL language. A catalog there
- for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held
- to the same parity check. Adding a language must not require forking the image.
-- **An unknown translation key renders as itself.** That single rule is what lets a nav label,
- branding, or a menu `rename` be either a key or plain text without a second field or a migration.
- Don't "fix" it into a loud failure: a manifest with plain labels must keep working.
-- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value,
- so nothing is double-escaped; a message carrying markup uses `<%- %>`, and then its `{{vars}}` are
- escaped at the call site (see `views/partials/pagination.ejs`). Don't move escaping into `t()` —
- every other value in a view would then be the odd one out.
-- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
- `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
- concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
- and tokens sit in that file between login and logout. Same class: concurrent runs share the
- workspace dir, so ci.sh's web-image build races another run's container creation on the
- `-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
- `concurrency` group if it ever bites.
-- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
- workflow reads a markdown file (`README-dockerhub.md` is pasted into Docker Hub by hand), so a
- nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it
- would spend the full gate on one. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
- rename detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs
- and skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a
- *text* guard — the test image (`node:24.19.0-alpine3.24`) ships neither `git` nor `bash`, so it
- cannot exercise the function; behaviour was verified against a scratch repo across ten scenarios.
- Revisit if a `.md` ever becomes load-bearing. Decided 2026-08-05.
-- **Plainpages is pre-announcement: no tags, no releases.** The repo carried tags up to `v0.2.2` from
- the `auto-release` job; all of them — and the semver container tags — were deleted 2026-08-05, and
- the job is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe
- direction on every unknown-`vars` path). A version only communicates to consumers, and there are
- none; same reasoning that freezes `HOST_API_VERSION` at 1.0.0. Note the coupling:
- `registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged,
- so with zero tags only branch heads survive the nightly prune — a hand-cut tag must sit on `main`'s
- tip. `mirror.yml` pushes tags with `--prune` so the deletions actually reach the public GitHub
- mirror; that makes the runner's tag view load-bearing (hence `fetch-tags: true`) and means a tag
- or Release created on GitHub is swept away, so releases are cut on Gitea only. Valid until the
- maintainer says Plainpages is ready to show people.
+ href in `localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a
+ hidden `locale` input, since a GET submit replaces the whole query string. **A form's `action`
+ counts as a link** — sign-out, consent and auth-card forms carry it too, or picking a language and
+ then saving anything drops back to `Accept-Language`. Putting the obligation on each call site was
+ tried and missed five of eight sites in one commit. `ctx.localeHref` remains for hrefs a plugin's
+ own markup emits. The one round-trip that cannot carry it is the Kratos sign-in POST (absolute
+ off-site URL).
+- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
+ page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
+ `localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
+ `data` so a collision loses the key instead of breaking the shell.
+- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
+ — keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
+ mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
+ `en-US` per key), never one the host lacks.
+- **`locales/` at the repo root is a drop-in mount**, like `plugins/` and `config/` — `locales/.ts`
+ for the core, `locales/plugins//.ts` for a plugin; a new tag adds a language, an existing
+ one replaces that catalog wholesale. Adding a language must never require forking the image. The
+ SHIPPED `en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is
+ checked rather than trusted (one compared only against itself would boot green with the whole UI
+ rendering keys).
+- **The language picker is on every page, POST-rendered ones included.** A POST-rendered URL often
+ answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target
+ (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin Referer, else `/`.
+ Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker
+ is expected on literally every page — if that softens, hiding it after a POST is simpler.
+- **An unknown translation key renders as itself.** That single rule lets a nav label, branding, or a
+ menu `rename` be either a key or plain text without a second field or a migration. Don't "fix" it
+ into a loud failure: a manifest with plain labels must keep working.
+- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value;
+ one carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move
+ escaping into `t()` — every other value in a view would become the odd one out.
+- **RTL is out of scope until there is a real use case.** `textDirection` sets `` because
+ that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL
+ locale needs those moved to logical ones first. Don't convert the CSS or file findings about it on
+ spec. Valid while no deployment needs an RTL language.
+
+### UI
+
- **A dropdown is a `` + `[popover]`, never a ``.** The browser then
- owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole
- point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s
- `overflow`. Four rules hold it together, none of them cosmetic. The panel carries
- **`position-anchor: auto`** — a bare `anchor()` resolves to nothing in Chromium, Firefox *and*
- WebKit alike, which is why the popover test in `visual.spec.ts` runs in all three rather
- than resting on a one-time manual measurement. The panel stays the trigger's **next sibling inside
- the `.menu` wrapper**, because the open-state style and the old-browser fallback both read that
- adjacency, and a two-element partial cannot be dropped into an arbitrary layout. The `menu` partial
- **requires a caller-named `id`** and fails loud without one: it is the `popovertarget` idref, and
- generated random ids were tried and rejected the same day — nondeterministic HTML forecloses the
- still-open caching decision and names nothing a reader can use. And **neither `aria-expanded` nor
- `aria-haspopup` is written**: a zero-JS invoker cannot keep the first truthful, and the second would
- promise `role="menu"` keyboard semantics these panels do not implement. `` stays where it
- means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
- profile menu because its trigger composes escaped user values and its one item is a CSRF POST form,
- neither of which the partial's `Item` shapes cover — keep the two in step, or fold it in if
- `todo.md`'s "does the profile dropdown still earn a dropdown" settles the other way. Decided 2026-08-05.
+ owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the
+ top layer, so a row kebab is no longer clipped by `.table-wrap`'s `overflow`. Four rules hold it
+ together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in
+ all three engines); it stays the trigger's **next sibling inside the `.menu` wrapper**, which the
+ open-state style and the old-browser fallback both read; the partial **requires a caller-named `id`**
+ and fails loud without one, since that is the `popovertarget` idref (generated ids were tried and
+ rejected — nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
+ `aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the second
+ would promise `role="menu"` semantics these panels don't implement. `` stays where it means
+ disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the profile
+ menu (its trigger composes escaped user values and its one item is a CSRF POST form, neither of which
+ the partial's `Item` shapes cover) — keep the two in step.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
- deliberately not re-exported from `#plugin-api`, and README → Nav & permission gates already tells an
- author that using a new icon means registering it there. So the palette may narrow when the last
- reference to an id goes — `i-gear` left with the settings menu 2026-08-05 — and a plugin needing one
- gets it re-registered in the same change. Accepted cost: an unknown sprite id renders a blank icon
- instead of failing loud; the `every icon resolves to a defined ` e2e test catches it for
- anything reaching the nav. Removing an id is a core edit, so weigh it per icon rather than sweeping the
- registry — a few ids are registered ahead of a caller (see `todo.md`).
+ deliberately not re-exported from `#plugin-api`; README → Nav & permission gates tells an author that
+ a new icon means registering it there. So the palette may narrow when the last reference to an id
+ goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an unknown
+ sprite id renders blank instead of failing loud (the `every icon resolves` e2e test catches
+ anything reaching the nav). Removing an id is a core edit — weigh it per icon rather than sweeping.
+
+### Build, test & release
+
+- **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
+ bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
+ destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
+ sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
+ files, `.dockerignore` the image).
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
- `e2e-tests/console-guard.ts`, which watches every page a test opens: a console error or warning, or
- an uncaught exception, fails that test. A zero-JS app has nothing to say in the console, so the bar
- is *zero* rather than a curated list of tolerated noise — and the two exceptions are explicit and
- narrow: one module-level allowance for the COOP header Chromium drops because the e2e stacks serve
- plain http over container hostnames (a deployment serves https, where it applies), and
- `allowConsole(re)` for a test whose own page provokes a message on purpose — the 404 spec, whose
- navigation Chromium and WebKit log. Each record carries the message's origin URL, so that allowance
- can name the page under test and still see a sub-resource of it 404. `src/e2e-console-guard.test.ts`
- locks the wiring in the *unit* gate: a spec importing `test` straight from Playwright — or minting a
- page with a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. The buffer
- clears at teardown rather than setup so a `beforeAll` is watched too (full-flow runs a whole login in
- one); the accepted cost is that a page outliving its test, as a serial describe's does, can log late
- and fail the next test instead of its own. Verified by negative control in all three engines.
+ `e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
+ any page it opened. A zero-JS app has nothing to say in the console, so the bar is *zero* rather than
+ a curated tolerance list; the two exceptions are narrow — a module-level allowance for the COOP header
+ Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
+ a test whose own page provokes a message on purpose. `src/e2e-console-guard.test.ts` locks the wiring
+ in the *unit* gate, since a spec importing `test` straight from Playwright — or minting a page with
+ a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. The buffer clears at
+ teardown so a `beforeAll` is watched too; accepted cost is that a page outliving its test can log
+ late and fail the next one.
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
- `visual.spec.ts` + `language.spec.ts` are side-effect-free, so three parallel runs don't collide,
- and a console message only appears in the engine that renders the page — the reason the per-test
- `@engines` tag is gone: the whole Ory-free suite is the engine matrix now (`ORY_FREE` in
+ `visual.spec.ts` + `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
+ console message only appears in the engine that renders the page (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
- where a second engine's run would race the first, so widening them means giving each engine its own
- stack. Screenshots are written per project name for the same reason. Decided 2026-08-05.
+ so widening them means a stack per engine. Screenshots are written per project name.
+- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
+ workflow reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to
+ skip as `README.md`. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: rename
+ detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs and
+ skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a *text*
+ guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes load-bearing.
+- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
+ `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
+ can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
+ file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
+ web-image build races another run's container creation on the `-web` tag. Accepted for a
+ single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
+- **Plainpages is pre-announcement: no tags, no releases.** All tags and semver container tags were
+ deleted, and `auto-release` is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped,
+ the fail-safe direction on every unknown-`vars` path). A version only communicates to consumers and
+ there are none — the same reasoning that freezes `HOST_API_VERSION`. Two couplings: `registry-cleanup`
+ keeps a hash image only while its commit is a branch head *or* release-tagged, so with zero tags a
+ hand-cut tag must sit on `main`'s tip; and `mirror.yml` pushes tags with `--prune` (so its
+ `fetch-tags: true` is load-bearing), meaning a tag or Release created on GitHub is swept away and
+ releases are cut on Gitea only. Valid until the
+ maintainer says Plainpages is ready to show people.
+- **A stricter manifest rule breaks already-copied plugins, and while `HOST_API_VERSION` is frozen the
+ failure names a symptom rather than the cause.** `plugins/` is an operator-owned drop-in mount, so an
+ operator's copy is whatever version they took. `checkApiVersion` is the right mechanism — a breaking
+ manifest change bumps the major and a stale plugin is refused by *version* — but that only works once
+ the freeze lifts. Until then a stricter rule ships with a README → Upgrading entry and a re-copy hint
+ in the discovery error. Fail-loud stays right either way: the alternative is a route gating on a name
+ nobody can be granted, i.e. a permanent silent 403. **Valid while `HOST_API_VERSION` stays frozen.**
## Docker only — no host tooling
@@ -336,102 +284,76 @@ docker compose -f compose.yml up --build -d # production
`README.md` serves two readers, in this order — preserve it when editing:
-1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets
- the stack up (`docker compose up`, sign in) and a *minimal* plugin live. Nothing comes
- before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the
- example plugin as small as possible; deeper detail lives in its own section, linked.
-2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then
- sections ordered by **what a developer adopting Plainpages reaches for, in priority
- order** — not by architectural layering. The value that sets the order: getting up and
- running **building plugins** comes first, then **configuring and securing** the system
- (Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
- deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
- Users, groups & permissions → Building plugins → menu/blocks/interactivity →
- Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
- JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
- it by this value (how early an adopter needs it), not by where it sits in the stack.
+1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the
+ stack up and a *minimal* plugin live. Nothing comes before Quick start — no philosophy, no
+ rationale. Keep its commands copy-pasteable; deeper detail lives in its own section, linked.
+2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
+ by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
+ & permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
+ Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
+ map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
+ permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
+ without the model, and it is the one home for that model.
- **Users, groups & permissions precedes Building plugins** because a manifest's
- `permission:` gate is unreadable without the model, and operators need it as much as plugin
- authors. It is the one home for that model — the plugin and auth sections link to it rather
- than restating it.
+When editing: put content in the section it belongs to; keep the ToC in sync when you add/rename/
+remove an `H2`/`H3`; state each fact in one home and link to it.
-When editing: put content in the section it belongs to (don't prepend rationale above Quick
-start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
-one home, linking to it rather than restating (credentials, env vars, rotation steps).
-
-**Don't document internals here.** How a script reaches a decision, why one run behaved
-differently from another, what a function guards — a developer doesn't need it day to day and
-can read it off the code or a run's log in seconds. Prose like that only makes the README
-longer and harder to consume, for humans and machines alike. It belongs in the code it
-describes, or nowhere. The README earns its length on what you cannot dig out: how to use and
-operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens).
-Same test before adding a row to a table or the file map — a clause, not a paragraph.
+**Don't document internals here.** How a script reaches a decision, what a function guards — a
+developer can read that off the code in seconds, and it only makes the README longer for humans and
+machines alike. It belongs in the code, or nowhere. The README earns its length on what you cannot
+dig out: how to use and operate Plainpages, the external contracts, and one-time setup. Same test
+before adding a row to a table or the file map — a clause, not a paragraph.
## Rules
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
- (`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or
- decorators. Import local modules with their `.ts` extension.
-- **No `.mjs`.** Write modules as `.ts` (Prio 1) — even standalone scripts run in bare
- `node:24` containers (the e2e mock servers, `examples/shifts-upstream/server.ts`): Node
- strips types and detects ESM from syntax, no package.json needed. If a file genuinely
- must be plain JavaScript, use `.js` (Prio 2); `"type": "module"` is already set in both
- `package.json`s, so `.js` is ESM.
+ (`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
+ local modules with their `.ts` extension.
+- **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers
+ (the e2e mock servers, `examples/shifts-upstream/server.ts`). If a file genuinely must be plain
+ JavaScript, use `.js`; `"type": "module"` is set in both `package.json`s, so `.js` is ESM.
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
- Before finishing a change, run the typecheck and tests above; both must pass.
- Tests use the built-in `node --test` runner — no test framework dependency.
-- English everywhere. Keep code comments short and information-dense. Self explained code
- without any comment at all is the preferred solution.
-- Do not comment about history in the code or README. Like "This function included X before,
- but it moved to Y".
-- Do not comment about the absence of things, if it is not very unexpected. Banned is things
- like "This function does not calculate pi, that is done in function Z".
-- Pin all dependencies and Docker images to exact, human-readable **semantic
- versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
- exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
- `node:24.16.0-alpine3.24`).
+- English everywhere. Keep code comments short and information-dense; self-explained code with no
+ comment at all is preferred.
+- Do not comment about history ("this moved from X"), or about the absence of things.
+- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
+ ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
+ by tag.
- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive
- contract changes (i18n added four `RequestContext` fields and several barrel exports without a
- minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild,
- a version bump can only produce noise. The promotion trigger is the first external plugin — from
- then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03.
- **The frozen surface includes `views/partials/*.ejs`**, not just the manifest and the barrel: the
- view resolver makes every core partial an `include()` root for a plugin's views, so their option
- names and emitted markup are author-visible (under this freeze the popover change dropped the `menu`
- partial's `open?` and rewrote its markup). Know the hole that leaves — discovery fails loud on a bad
- `apiVersion`, but `include("menu", { open: true })` silently ignores the option and a plugin styling
- `.menu > summary` silently loses it. Promotion must cover the partial vocabulary too. Added 2026-08-05.
-- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
- plugin was built against — bumped by hand on rebuild, **never** the host's
- `HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
- host, so `checkApiVersion` can never fire and a breaking change slips through silently.
-- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler
- per `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed
- `%`-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher
- that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
- has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
- resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
+ contract changes. With no third-party plugin in the wild a bump can only produce noise. The
+ promotion trigger is the first external plugin — from then on follow the versioning table in
+ README → Contract versioning. **The frozen surface includes `views/partials/*.ejs`**: the view
+ resolver makes every core partial an `include()` root for a plugin's views, so their option names
+ and emitted markup are author-visible. Know the hole that leaves — discovery fails loud on a bad
+ `apiVersion`, but `include("menu", { open: true })` silently ignores a dropped option. Promotion
+ must cover the partial vocabulary too.
+- A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built
+ against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing
+ the constant makes every plugin always equal the host, so `checkApiVersion` can never fire.
+- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler per
+ `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
+ Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the
+ URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
+ into a small `withX` wrapper — see `examples/plugins/admin/`.
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
- routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what
- remains is orchestration. Planned split along those seams; don't grow it further without taking one
- out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change.
-- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
- agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
+ routing, 405/404 and error mapping. The pure parts are already extracted and separately tested;
+ what remains is orchestration. Planned split along those seams; don't grow it further without
+ taking one out.
+- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
in words; handlers pass `ctx.t`.
-- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in",
- "log out" or "sign up", inflections included — a second spelling for one button reads as a second
- thing; the noun ("a sign-in error", "the sign-in identifier") is unaffected. A plugin's catalog and
- every other locale follow the same rule in their own language. An unmapped Kratos id renders
- Kratos' own wording — map the id when it matters. **Held by the author, never by a test:** as the
- UI grows, slightly different wording is often the right call, and a check that fails the build on
- a word takes that judgment away. Maintainer's call 2026-08-05, dropping the guard that shipped
- with the rule.
-- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
- for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
- and not "ids=x,y".
+- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in", "log
+ out" or "sign up", inflections included — a second spelling for one button reads as a second thing;
+ the noun ("a sign-in error") is unaffected. A plugin's catalog and every other locale follow the
+ same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
+ it matters. **Held by the author, never by a test:** slightly different wording is often the right
+ call, and a build-failing check takes that judgment away.
+- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
+ example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
+ `ids=x,y`.
diff --git a/Dockerfile b/Dockerfile
index 4192b48..f077b3f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,14 +1,17 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.19.0-alpine3.24
+# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
+# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
+# Dev deps kept so typecheck/test run in-image.
+COPY package.json package-lock.json .npmrc /deps/
+RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
+
WORKDIR /app
-
-# Reproducible install from the lockfile. Dev deps kept so typecheck/test run in-image.
-COPY package.json package-lock.json .npmrc ./
-RUN npm ci
-
COPY . .
+# The host uid running a lockfile edit has no home here, so npm's cache would land in unwritable /.
+ENV npm_config_cache=/tmp/.npm
ENV PORT=3000
EXPOSE 3000
CMD ["node", "src/server.ts"]
diff --git a/README.md b/README.md
index 3024af3..682e805 100644
--- a/README.md
+++ b/README.md
@@ -1768,6 +1768,9 @@ so for now the error names the rule it tripped instead.
`users:`/`groups:`/`oauth2-clients:` × `read`/`write`, so a copy taken before this needs re-copying.
`ADMIN_PERMISSIONS` is held to the same rule, but an unusable value there is dropped with a warning
rather than failing the boot. See [Naming a permission](#naming-a-permission).
+- **Deps moved to `/node_modules`, above `/app`** (2026-08-05). Run `rmdir node_modules` after
+ pulling (it is empty — no `sudo`) and `docker volume prune`. Keep that path clear: anything there
+ silently shadows the image's deps.
## Observability
@@ -1964,9 +1967,14 @@ README-dockerhub.md The Docker Hub repository description (docker.io/larvit/pla
- **New page in a plugin:** add a route + handler to the plugin manifest and a template in
its `views/`.
- **Static asset:** drop it in the plugin's `public/`; served at `/public//`.
-- **New dependency:** `docker compose run --rm web npm install ` (updates `package.json`
- + `package-lock.json`), then `docker compose build`. Keep deps minimal — prefer the Node
- standard library, and prefer an Ory REST call over an SDK.
+- **New dependency:** deps live in the image, so update the manifest + lockfile and rebuild —
+ `--package-lock-only` writes nothing into the checkout, `--user` keeps the two files yours.
+ Keep deps minimal — prefer the Node standard library, and an Ory REST call over an SDK.
+
+ ```bash
+ docker compose run --rm --no-deps --user "$(id -u):$(id -g)" web npm install --package-lock-only
+ docker compose build
+ ```
All versions are pinned to **exact, human-readable semantic versions** (no ranges, no
digests): npm deps via `.npmrc` (`save-exact=true`) + the committed lockfile (`npm ci`), and
diff --git a/compose.override.yml b/compose.override.yml
index 52e3e94..29db789 100644
--- a/compose.override.yml
+++ b/compose.override.yml
@@ -18,7 +18,6 @@ services:
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
volumes:
- .:/app
- - /app/node_modules
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
@@ -32,7 +31,6 @@ services:
bootstrap:
volumes:
- .:/app
- - /app/node_modules
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
diff --git a/json b/json
deleted file mode 100644
index 7dd567a..0000000
--- a/json
+++ /dev/null
@@ -1 +0,0 @@
-{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"}
\ No newline at end of file
diff --git a/src/compose.test.ts b/src/compose.test.ts
index 8ae5d54..340f014 100644
--- a/src/compose.test.ts
+++ b/src/compose.test.ts
@@ -6,7 +6,7 @@
// by running the stack; this catches edits.
import { test } from "node:test";
import assert from "node:assert/strict";
-import { readFileSync } from "node:fs";
+import { readFileSync, readdirSync } from "node:fs";
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
const compose = read("compose.yml");
@@ -89,6 +89,23 @@ test("a one-shot bootstrap seeds the stack before web starts", () => {
"web waits for bootstrap to finish");
});
+test("deps live above WORKDIR, so no mount creates a root-owned dir in the checkout", () => {
+ // A volume at /app/node_modules would leave a root-owned dir in the checkout (AGENTS.md).
+ const dockerfile = read("Dockerfile");
+ // split() returns the whole file when the marker is missing, widening "before" to "anywhere".
+ assert.ok(dockerfile.includes("WORKDIR /app"), "the app dir is /app");
+ const beforeWorkdir = dockerfile.split("WORKDIR /app")[0]!;
+ assert.match(beforeWorkdir, /npm ci/, "npm ci runs before WORKDIR /app");
+ assert.match(beforeWorkdir, /mv\s+node_modules\s+\/node_modules/, "and its tree lands at /node_modules");
+
+ const composeFiles = (dir: string) =>
+ readdirSync(new URL(`../${dir}`, import.meta.url))
+ .filter((f) => f.startsWith("compose.") && f.endsWith(".yml"))
+ .map((f) => `${dir}${f}`);
+ for (const f of [...composeFiles(""), ...composeFiles("e2e-tests/")])
+ assert.ok(!read(f).includes("/app/node_modules"), `${f} mounts nothing at /app/node_modules`);
+});
+
test("the visual E2E does not drag in the Ory stack", () => {
// web's Ory deps are reset for E2E (the dashboard is mock data — no Ory needed).
assert.match(visual, /depends_on:\s*!reset\b/, "E2E resets web's depends_on");
diff --git a/src/ui/icons.test.ts b/src/ui/icons.test.ts
index 0587733..7c91961 100644
--- a/src/ui/icons.test.ts
+++ b/src/ui/icons.test.ts
@@ -7,7 +7,8 @@ import ejs from "ejs";
import { ICON_NAMES, buildIconSprite } from "./icons.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
-const lucideDir = join(rootDir, "node_modules", "lucide-static", "icons");
+// Resolved by specifier, not by path: the install lives above the app dir, not in it (Dockerfile).
+const lucideDir = join(dirname(fileURLToPath(import.meta.resolve("lucide-static/package.json"))), "icons");
const partial = join(rootDir, "views", "partials", "icons.ejs");
const symbolInner = (sprite: string, id: string): string =>
diff --git a/todo.md b/todo.md
index e26c6b4..737be68 100644
--- a/todo.md
+++ b/todo.md
@@ -2,7 +2,7 @@
## Unfinnished work
-- [ ] node_modules gets installed straight to the root folder with root permissions, it should at the very least be same owner as the one running the docker process, or built inside the docker image.
+- [ ] `e2e-tests/artifacts/` is written `root:root` into the checkout and needs `sudo` to delete — the Playwright container runs as root. Unlike the node_modules mountpoint, a container `user:` would fix it, but compose has no `$UID` of its own (needs an `.env` or `id -u` via `ci.sh`) and CI's artifact upload reads that dir. Found 2026-08-05.
- [ ] Add a way to configure plugins directly when installing. Most reasonable is an .env file in the plugin folder, I think, but I am open to suggestions.
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
- [ ] Guard the group paths to self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Same scope the deleted Permissions screen had, and recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query. Raised by the stability review 2026-08-05.
@@ -38,6 +38,7 @@ Prioritized. Overall verdict: architecture is sound (contract-first plugin API,
## Finnished work
+- [x] node_modules gets installed straight to the root folder with root permissions, it should at the very least be same owner as the one running the docker process, or built inside the docker image. (It *was* built in the image; the checkout got an empty root-owned dir — the mountpoint for `compose.override.yml`'s `- /app/node_modules` volume. Re-owning it is impossible (the daemon creates mount destinations as root whatever `--user` says), so deps moved to `/node_modules` above `WORKDIR /app` and the volume is gone. See AGENTS.md.)
- [x] Document permissions format so it is folled going forward: :, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → [Naming a permission](README.md#naming-a-permission) is the one home for the rule, and the host *enforces* it at discovery — `isValidPermissionName` in `src/plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every declared name — so a badly-named permission stops the boot like any other bad manifest, for every plugin rather than only ones the admin GUI touches. `admin` is gone, split per screen into `users:`, `groups:` and `oauth2-clients:` × `read`/`write`. The read/write split is real, not cosmetic: `users:read` opens the list and is refused on every POST, and the Admin nav header lost its own gate so each screen is filtered by its own `:read` — hold none of the three and `composeNav` drops the emptied header (which needs the header to carry no `href`, now asserted). Two things had to be fixed to get here. The permission path validator was the *group* regex with no colon, so `/admin/permissions/scheduling:read` already 404'd. And `ADMIN_PERMISSIONS` defaulting to empty exposed that `bootstrap` never bind-mounted `plugins/` at all — it discovered only the image's empty copy, so a dropped-in plugin's permissions were never seeded; the mount lives in `compose.override.yml` (dev-only, mirroring `web`'s `.:/app`) because the base file gives both services the same baked copy and a base-file mount would collide with the e2e stacks that bind plugins *inside* that path. Quick start now says `docker compose up -d`, which re-runs the one-shot. Verified end to end on a live stack.)
- [x] Permissions should be a list in code. Since no permissions exists in the database out of the box, but there are a fixed number of permissions in the plugins that the end consumer and user of plain pages can use, these permissions must surface to the UI somehow. The effects is that the permissions page should be deleted completely, and the users and groups pages should gain the functionality to add permissions to their things instead, provided the user have the right permissiosn to do so, of course. Run the product reviewer agent on this todo also. (The host collects every installed plugin's declarations into one catalog — `declaredPermissions()` → `ctx.declaredPermissions`, deduped and sorted, computed once at wiring — and that catalog *is* the fixed list. The Permissions screen is deleted outright: its module, tests, three views, two partials and 29 catalog keys per locale. Users and Groups each gained a checkbox list of the catalog, ticked where held; the whole set posts back, so what is submitted is the desired state and `grantDiff` turns it into grants + revokes. Two properties earn their tests: a crafted POST cannot grant a name no plugin declares, and a held-but-undeclared name — left over from an uninstalled plugin — is never silently revoked by an unrelated save, since the picker only speaks for what it showed. A user's own change revokes their live tokens; a group's reaches members at their next re-mint, the documented transitive lag. Keto stays optional on the Users screen: without it the page still lists and edits, minus the picker. Maintainer's call 2026-08-05 to keep the OAuth2-clients screen and gate it `oauth2-clients:read/write` — permissions and OAuth2 are orthogonal, scopes say what an *app* may see and permissions what a *user* may do, so the screen only ever needed *a* gate.)
- [x] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix. (Every spec takes its `test` from `e2e-tests/console-guard.ts`, which watches every page a test opens — `console.error`, `console.warning`, and uncaught page errors — and fails the test that provoked one, at whatever step. The bar is zero rather than a curated tolerance list: the app ships no client JavaScript, so a message means a broken sub-resource, a rejected attribute, or an engine refusing a feature. Two narrow escapes, both explicit: the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), and per-test `allowConsole(/…/)` — used once, by the 404 spec, whose own navigation Chromium and WebKit log. **All browsers** is now literal for the Ory-free suites: `visual.spec.ts` + `language.spec.ts` run in Chromium, Firefox *and* WebKit — the per-test `@engines` tag is gone, and screenshots are written per project so the three don't fight over one file — which is what makes an engine-specific message visible at all. The Ory-backed suites write users, groups and sessions to one shared backend, so they stay on Chromium; widening them needs a stack per engine. Nothing in the app had to be fixed: the sweep found only the two above. Verified by negative control — an injected `console.warn` failed the test in all three engines and an injected `console.error` failed on full-flow's shared serial page — which also caught the guard registering that page twice. `src/e2e-console-guard.test.ts` locks the wiring in the *unit* gate, since a spec importing `test` straight from Playwright would run unwatched and green.)