Move the decisions out of AGENTS.md into docs/decisions.md #225

Merged
lilleman merged 5 commits from item-60 into main 2026-11-08 21:16:56 +01:00
11 changed files with 612 additions and 290 deletions
+3 -2
View File
@@ -69,8 +69,9 @@ jobs:
if: always()
run: |
set -uo pipefail
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
# fail a release that published, nor skip the overview job that follows.
# This is cleanup: it must not fail a release that published, nor skip the overview job
# that follows. The runner's Docker config is shared, so a logout can lose a race
# (docs/decisions.md → "CI docker logins share the runner host's Docker config").
docker logout gitea.larvit.se || true
docker logout docker.io || true
+53 -268
View File
@@ -10,8 +10,9 @@ change, sweep the prose you touched — this file, `README.md`, the example READ
comments — and cut it back to what a competent reader could not infer:
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
the decision and the reason it *currently* turns on, nothing else.
rejected", "(declined twice)", dated changelog entries (a decision's date in `docs/decisions.md`
is not one), or the symptom that prompted a fix. Record the decision and the reason it
*currently* turns on, nothing else.
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
not trimming.
@@ -67,288 +68,72 @@ branch, create a PR and merge it when the CI/CD turns green.
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
deliberate **non-goal**.
## Deliberate architectural deviations (don't re-flag)
## Decisions
Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
Revisit only if the stated reason stops holding.
Each title below is an entry in `docs/decisions.md`, with its reasoning and the premise it rests
on; a review honours them.
### Structure & contracts
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
and `test-tooling/` (modules only tests import), with `server.ts`/`config.ts`/`logger.ts` and
the topology-guard `*.test.ts` at the root; tests are co-located. Add a new module to the folder owning its concern. 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 through a barrel** — `@plainpages/plugin-api` →
`plugin-api/index.ts` → `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; don't "fix"
either back to a relative path. Three consequences:
- `@plainpages/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.
- **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
`package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
`plugin-api.test.ts` guards by asserting both paths reach one module.
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards
both halves, because one value imported from the wrong module breaks it invisibly). It is never
re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so
don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than
stored, which is what keeps the host stateless — whoever holds that secret holds every plugin
database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only
(`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
packaging promise, and a plugin must still depend on its own driver.
- **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and
takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the
design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key
`url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the
`camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin
collision caught by `findConflicts` (`plugin-host/conflicts.ts`); `required` and `default` are mutually exclusive, which is what
lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author
casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`
— not even as a mask or a length. An author mistake is refused at discovery, a bad operator value
refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns (the orphan-database precedent).
- **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
containment, and README says so rather than implying a sandbox. Consistent with priority #7
(crash-isolation is a non-goal). `server.ts` still deletes the secret from `process.env` right
after `loadConfig`, which is before discovery imports any plugin module — the ordering is the whole
point, so move it earlier if anything, **never later**. **Valid while plugins are
operator-installed code, not third-party uploads.**
- **`ory/postgres/init/init.sql` is the only home for the Ory databases' ACL** — don't re-assert the
`REVOKE CONNECT` from `bootstrap`. `REVOKE` only *warns* when the caller doesn't own the database,
so under the least-privilege provisioning account the README recommends it would report success
while changing nothing, and it hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no
`kratos`. It runs only on **first init**, so a revoke added to it later never reaches a volume that
already exists — `docker compose down -v` is the dev remedy, a deployed install needs a migration.
- **`bootstrap.ts` stays under `src/auth/`** even though it now provisions plugin databases as well
as seeding Ory. It is the one-shot service's entrypoint, not an auth module; moving it to
`src/bootstrap.ts` would edit `compose.yml`, five e2e compose files and `src/compose.test.ts` for a
rename. Reconsider when a third seeding concern lands.
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** Adding
to the context costs a minor bump and removing one a major, so the shape errs small elsewhere. Pools
handed to a plugin are reaped on process exit — revisit if a plugin ever needs an orderly drain.
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
against that instead and boot fails loud. An operator's menu override has no use for
dependencies; if that changes, it needs the same package treatment.
- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a
warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper
loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway
because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins.
**Valid while nothing is installed in the wild.**
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
`tsconfig.include` and resolve the host through the barrels, 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 (`/`).
- **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.
- `src/` is grouped by concern, and the core ships no domain screens
- Plugins and config import the host only through the two barrels
- The Ory client types are contract-visible through the barrel
- The barrel is a package, so a plugin folder may carry its own `package.json`
- Plugin storage hands over credentials, not a client
- Plugin settings are declared, not discovered
- The trust boundary is the `web` process, not the plugin
- `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
- `bootstrap.ts` stays under `src/auth/`
- `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
- `config/` is a plain dir with no `package.json` of its own
- A plugin `package.json` without `"type": "module"` is refused, not warned
- `examples/` mirrors the drop-in mount dirs
- `ctx.chrome` is lazily memoized, never built unconditionally
- A plugin-owned render always runs on that plugin's context
- Email is delegated to Kratos
### 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 `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route, nav and profile-menu
`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.
- `<resource>` 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**, 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 to prove the container survives one). The seed is
a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
`docker compose up -d`, 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 `@plainpages/plugin-api`.** Inside the admin
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 gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups`
builds header nodes that carry no gate. Making `allows` fail closed would hide every
operator-grouped section. `session` exists because a plugin whose data is
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
name; the alternative, granting every newly registered user a permission, couples the identity
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
who would then inherit the previous holder's rows.
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
- **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`. 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. The group paths are
guarded too: unticking a permission on a group you are in, removing yourself (or a nested group you
are in) from its members, or deleting the group is refused when you would lose a permission you
don't hold directly.
- **`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.
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`; README → Security
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS.
**Valid while the session cookie stays `SameSite=Lax` or stricter.**
- **Plainpages says "user" everywhere; Ory's word is "identity".** 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.
- The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
- A permission name is always `<resource>:<action>`, enforced at discovery
- Permission names are authored in plugin code; only grants live in Keto
- A permission's `<resource>` is global, not plugin-scoped
- Declaring a permission stays optional
- `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
- `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
- A gate is one of `public`, `session` or `permission`, named exactly once
- The reference plugin's two shift pages duplicate a view model and markup
- A `:read`-only holder is never shown a write affordance
- `users:write` and `groups:write` each amount to full administrative access
- The CSRF token is unbound to the session
- Plainpages says "user" everywhere, where Ory says "identity"
### 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. 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, `pagination`,
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every 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`. The obligation stays on the building block, never on each
call site. `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 — as is `assetHref`.
- **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/<tag>.ts`
for the core, `locales/plugins/<id>/<tag>.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 `<html dir>` 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. Valid while no deployment needs an RTL language.
- The locale lives in the URL, never in a cookie
- The core building blocks carry the locale, so a plugin doesn't have to
- `locale` and the i18n view locals are reserved by the host
- Catalogs are checked at boot, not at render
- `locales/` at the repo root is a drop-in mount
- The language picker is on every page, POST-rendered ones included
- An unknown translation key renders as itself
- `t()` returns raw text, and the view escapes it
- RTL is out of scope until there is a real use case
### UI
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
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 not 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 (never generate one —
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
`aria-haspopup` is written**: every engine maintains the first itself on a declarative
`popovertarget` invoker, so a hand-written one replaces a live state with a static lie, and the
second would promise `role="menu"` semantics these panels don't implement. **That guarantee is the
declarative attribute's alone** — open a panel from script and no engine applies it, so
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` 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 Sign-out item is a CSRF POST form) — keep
the two in step.
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
a label click focuses it, and a browser scrolls a focused element into view.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
is deliberately not re-exported from `@plainpages/plugin-api`. 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 <use> resolves` e2e test
catches anything reaching the nav).
- A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
- The document is the one scroller
- `ICON_NAMES` is a host-owned registry, not a plugin contract
### 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).
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
a dev box may not have. Not universal: `bootstrap` writes `jwks.json` as root when it is absent on
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
daemon-created as root and that uid then cannot write it (README → Upgrading); the runner image sets
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
`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. Accepted cost: 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 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,
so widening them means a stack per engine.
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** 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` would otherwise read as docs and skip 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`. This is why the Docker Hub overview is
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
skipped. `README.md` is the one markdown tests read, and a README-only change skips them. Accepted:
the `apiVersion` samples `release-tooling/contract-version.test.ts` checks are illustrative, and
the copies that matter (`examples/`, `views/`, the template) are gated. A Hub page command the
README dropped (`release-tooling/dockerhub-overview.test.ts`) fails the next gated change. **Valid while no markdown file is rendered or executed.**
- **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 `<project>-web` tag. Accepted for a
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
- Dependencies install to `/node_modules`, above `WORKDIR /app`
- A container whose output a human then edits or deletes runs as the invoking user
- Anything the browser logs fails the E2E test that provoked it
- The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium
- The docs-only CI skip is `*.md` anywhere in the tree
- CI docker logins share the runner host's Docker config
## Docker only — no host tooling
+521
View File
@@ -0,0 +1,521 @@
# Decisions
"Goal N" is README → Goals. "Code health" is the goal every project shares: the code is easy to
understand and change safely.
## `src/` is grouped by concern, and the core ships no domain screens
2026-06-24, the maintainer. Code health and goal 3. Valid while each concern folder stays one a
reader can name.
The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only
tests import), with `server.ts`, `config.ts`, `logger.ts` and the topology-guard `*.test.ts` at the
root. Tests are co-located. Add a new module to the folder owning its concern. Even the admin GUI
is a drop-in plugin (`examples/plugins/admin/`).
## Plugins and config import the host only through the two barrels
2026-08-17, the maintainer. Goal 7. Valid while plugin authors typecheck against a host tree rather
than a published package.
`@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and
`#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are
the whole contract surface, so don't "fix" either back to a relative path.
## The Ory client types are contract-visible through the barrel
2026-07-02, the maintainer. Goal 7. Valid while plugins get Ory admin clients through `ctx.system`.
`@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their
error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor.
## The barrel is a package, so a plugin folder may carry its own `package.json`
2026-08-17, the maintainer. Goal 7. Valid while plugins may depend on npm packages (README → Plugin
dependencies).
The Dockerfile links the barrel into `/node_modules`, above every plugin scope. Never let a copy
reach a plugin's own `node_modules`: two instances of the barrel break `instanceof` across the
boundary. `plugin-api.test.ts` guards this by asserting both paths reach one module.
## Plugin storage hands over credentials, not a client
2026-08-18, the maintainer. Goals 3 and 7. Valid while the host stays stateless (AGENTS.md →
priority 2).
README → Plugin storage describes the contract. The host takes `postgres` only to run the
provisioning DDL, and `storage-provisioning.ts` is the only module importing it. `storage.ts`
beside it stays pure, so `web` never loads a driver. `src/postgres.test.ts` guards both halves,
because one value imported from the wrong module breaks this invisibly. The driver is never
re-exported through the barrel, so no driver shape enters the contract.
Three properties hold the design together, so don't trade one away in isolation:
- Passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than stored, which keeps the host
stateless. Whoever holds that secret holds every plugin database, so it ranks with the DB password
itself.
- The provisioning DSN reaches `bootstrap` only (`src/compose.test.ts` guards the split).
- Provisioning never drops anything, so uninstalling a plugin cannot destroy data; boot logs the
orphans instead.
Because the host's copy sits in the ambient `/node_modules`, a plugin can `import "postgres"`
without declaring it. That is incidental, not a packaging promise: a plugin must still depend on its
own driver.
## Plugin settings are declared, not discovered
2026-08-23, the maintainer. Goals 7 and 10. Valid while settings reach plugins through the
environment (README → Plugin settings).
`settings.ts` is pure and takes the env as an argument, so the whole matrix unit-tests without a
stack. Four rules carry the design:
- The prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`: a plugin id `db` with key `url` would
otherwise name the host's own `PLUGIN_DB_URL`.
- Keys are camelCase, so the `camelCase → SNAKE_CASE` mapping is total and no two keys collide.
`findConflicts` (`plugin-host/conflicts.ts`) catches the residual cross-plugin collision.
- `required` and `default` are mutually exclusive. That lets `SettingsOf` type a declared key as
present rather than `T | undefined`, so no plugin author casts.
- A secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`, not even
as a mask or a length.
An author mistake is refused at discovery, a bad operator value refuses the boot, and a stray
`PLUGIN_SETTING_` variable only warns, as an orphan database does.
## The trust boundary is the `web` process, not the plugin
2026-08-19, the maintainer. Goal 7. Valid while plugins are operator-installed code, not third-party
uploads.
Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in
`web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So
cross-plugin DB isolation is containment, and README says so. This follows AGENTS.md → priority 7:
crash-isolation is a non-goal.
`server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery
imports any plugin module. That ordering is the whole point: move the line earlier if anything,
never later.
## `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
2026-08-19, the maintainer. Code health. Valid while Ory's databases are created by Postgres' first
init.
Don't re-assert the `REVOKE CONNECT` from `bootstrap`. `REVOKE` only warns when the caller doesn't
own the database, so under the least-privilege provisioning account the README recommends it would
report success while changing nothing. It also hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a
server with no `kratos`. The script runs only on first init, so a revoke added to it later never
reaches a volume that already exists: `docker compose down -v` is the dev remedy, and a deployed
install needs a migration.
## `bootstrap.ts` stays under `src/auth/`
2026-08-19, the maintainer. Code health. Valid until a third seeding concern lands.
It provisions plugin databases as well as seeding Ory, but it is the one-shot service's entrypoint,
not an auth module. Moving it to `src/bootstrap.ts` would edit `compose.yml`, five e2e compose files
and `src/compose.test.ts` for a rename.
## `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools.
Removing any field from the context costs a major bump, so all six stay. Adding one costs only a
minor, so the context otherwise gains a field only when a plugin needs it. Pools handed to a plugin
are reaped on process exit.
## `config/` is a plain dir with no `package.json` of its own
2026-07-01, the maintainer. Goal 7. Valid while an operator's menu override needs no dependencies.
With a `package.json` there, `#menu-config` resolves against it instead and boot fails loud. If the
override ever needs dependencies, `config/` needs the package treatment `plugins/` has.
## A plugin `package.json` without `"type": "module"` is refused, not warned
2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild.
Refusing is the safe direction: refuse→warn relaxes freely, while warn→refuse breaks installed
plugins. Allowing it would cost only a warning and a re-parse per file, not a break: Node detects
module syntax, so even a `.js` helper loads. The cost of refusing is that an operator on a read-only
third-party mount cannot apply the remedy.
## `examples/` mirrors the drop-in mount dirs
2026-07-01, the maintainer. Goal 9. Valid while the examples are meant to be copied in unchanged.
`examples/plugins/<id>/` copies to `plugins/<id>/`, and `examples/config/menu.ts` to
`config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve the host through the barrels,
so each typechecks in place and copies across unchanged. Never commit real plugins or config into
the root mount dirs: they ship empty.
## `ctx.chrome` is lazily memoized, never built unconditionally
2026-06-23, the maintainer. Goal 8. Valid while the public landing (`/`) renders without I/O.
Don't 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.
## A plugin-owned render always runs on that plugin's context
2026-08-03, the maintainer. Goal 7. Valid while each plugin has its own translator.
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
2026-06-23, the maintainer. Goal 3. Valid while Kratos owns the recovery and verification flows.
`web` never touches SMTP. Mail is customized through Kratos' `courier.template_override_path`.
## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
2026-08-03, the maintainer. Code health. Valid while Keto holds no namespace of its own.
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, and
groups nest. Ory's own "permission" (the `Resource` `permits`: view, edit, delete) is the separate
per-row tier.
## A permission name is always `<resource>:<action>`, enforced at discovery
2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors.
Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are
groups here. `shapeError` checks every route, nav and profile-menu `permission`, and every declared
name, with `isValidPermissionName` (`plugin-host/plugin.ts`). A bad name refuses the boot like any
other manifest rule. The check is not left to the admin GUI, which an operator removes by not
copying it in.
## Permission names are authored in plugin code; only grants live in Keto
2026-08-05, the maintainer. Goal 2. Valid while the admin screens offer only declared names.
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 there is 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.
## A permission's `<resource>` is global, not plugin-scoped
2026-08-05, the maintainer. Goal 7. Valid while cross-plugin sharing of permissions is wanted.
Hence `oauth2-clients`, not `clients`. Users are the host's, and plugins share them. The cost is
that collision-freedom is a convention rather than structural; the alternative penalizes the
sharing case.
## Declaring a permission stays optional
2026-08-05, the maintainer. Goal 7. Valid while plugins may share a permission.
Mandatory declaration would let `findConflicts` see every overlap, but it would then warn on exactly
the legitimate sharing case. Shape is enforced; declaration is not.
## `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
2026-08-05, the maintainer. Goal 10. Valid while `bootstrap` is the only writer of grants.
Fail-loud belongs at the manifest boundary, where a developer authored the mistake. `bootstrap`
gates `web`, so refusing operator env would take the whole stack down;
`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one.
The seed is a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
`docker compose up -d`, 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.
## `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route.
Inside the admin 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, and a route table must answer "what does this need?" on its own.
## A gate is one of `public`, `session` or `permission`, named exactly once
2026-09-02, the maintainer. Goal 7. Valid while routes and nav nodes are declared in plugin
manifests.
A route or nav node names exactly one gate. Discovery refuses none, two, and a flag spelled anything
but `true`, so a forgotten gate fails the boot rather than publishing a page. `src/auth/gate.ts` is
the one home of the rule that the plugin router, the host's own route table and the menu all read.
The rule binds manifests only; the comment on `allows` says why a node without a gate passes it.
`session` exists because a plugin whose data is the visitor's own (their upstream account, their own
tokens) has no distinction a permission could name. The alternative, granting every newly
registered user a permission, couples the identity lifecycle to a Keto write that nothing retries
when it fails. A page scoped to "mine" joins on `ctx.user.id`, never the email: an address is
user-changeable and can be reassigned to someone who would then inherit the previous holder's rows.
## The reference plugin's two shift pages duplicate a view model and markup
2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching
artifact rather than a maintained product.
Each page then reads top to bottom on its own, and an example rarely changes, so the two copies cost
little.
## A `:read`-only holder is never shown a write affordance
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits `:read` from `:write`.
The list and detail models carry `canWrite`, and the views drop create, save, delete, add and
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`.
Three guards go with it:
- You cannot revoke your own direct grants, since undoing that self-lockout needs a `curl` against
Keto.
- A permission held through a group renders ticked but disabled, because unticked would state the
opposite of the truth.
- Unticking a permission on a group you are in, removing yourself (or a nested group you are in)
from its members, or deleting the group is refused when you would lose a permission you don't hold
directly. Undoing that self-lockout would need a `curl` against Keto too.
## `users:write` and `groups:write` each amount to full administrative access
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits its write permissions by
resource.
`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.
## The CSRF token is unbound to the session
2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter.
Leaving the token unbound is the usual cost of a signed double-submit token (`src/auth/csrf.ts`;
README → Security model). `SameSite=Lax` and HSTS cover that cost.
## Plainpages says "user" everywhere, where Ory says "identity"
2026-08-05, the maintainer. Code health. Valid while Kratos' wire shape names the DTO `Identity`.
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.
## The locale lives in the URL, never in a cookie
2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address.
The host resolves the locale from `?locale=sv-SE`, then `Accept-Language`, then `en-US`. When the
URL asked for a locale, 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, so a plugin doesn't have to
2026-08-03, the maintainer. Goal 1. Valid while the locale lives in the URL.
The shell, `pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu`
wrap every href in `localeHref`. Nav and sign-in are wrapped in `chrome.ts`. The two GET forms carry
the locale 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 the locale too, or
picking a language and then saving anything drops back to `Accept-Language`. The obligation stays on
the building block, never on each call site. `ctx.localeHref` remains for hrefs a plugin's own
markup emits. The one round-trip that cannot carry the locale is the Kratos sign-in POST, to an
absolute off-site URL.
## `locale` and the i18n view locals are reserved by the host
2026-08-03, the maintainer. Goal 7. Valid while the locale lives in the URL.
`locale` is in `parseListQuery`'s reserved set, so a localized list page doesn't hand a plugin a
phantom `locale` filter. The view locals `t`, `locale`, `locales`, `localeHref`, `localeParam`,
`localeSwitch`, `dir` and `assetHref` are 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
2026-08-03, the maintainer. Goal 7. Valid while catalogs are TypeScript modules loaded at boot.
Every locale is compared against its set's `en-US`: keys, string-vs-plural kind, and the plural
categories `Intl.PluralRules` requires. A mismatch stops startup. A plugin may ship fewer locales
than the host, with its strings falling back to `en-US` per key, but never one the host lacks.
## `locales/` at the repo root is a drop-in mount
2026-08-03, the maintainer. Goal 7. Valid while adding a language must not require forking the
image.
Like `plugins/` and `config/`: `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts` for
a plugin. A new tag adds a language; an existing one replaces that catalog wholesale. 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
2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if
that softens, hiding it after a POST is simpler.
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 `/`. The accepted cost: switching language there leaves that POST's own result behind.
## An unknown translation key renders as itself
2026-08-03, the maintainer. Goal 7. Valid while labels may be either a key or plain text.
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, and the view escapes it
2026-08-03, the maintainer. Code health. Valid while views escape with `<%= %>`.
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
2026-08-04, the maintainer. Goal 6. Valid while no deployment needs an RTL language.
`textDirection` sets `<html dir>` because that is free and correct, but the stylesheet keeps
physical `left` and `right` properties. A genuine RTL locale needs those moved to logical ones
first.
## A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
2026-08-05, the maintainer. Goals 4 and 6. Valid while the popover API is the only zero-JS way to
dismiss on an outside click.
The browser owns open and close, and the panel sits in the top layer, so a row kebab is not 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.
- The panel 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. Never generate one: nondeterministic HTML forecloses caching.
- Neither `aria-expanded` nor `aria-haspopup` is written. Every engine maintains the first itself on
a declarative `popovertarget` invoker, so a hand-written one replaces a live state with a static
lie. The second would promise `role="menu"` semantics these panels don't implement.
That `aria-expanded` guarantee is the declarative attribute's alone: open a panel from script and no
engine applies it, so "enhancing" one of these triggers would cost it its accessibility. `<details>`
stays where it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same
block for the profile menu, and its comment says why. Keep the two in step.
## The document is the one scroller
2026-09-09, the maintainer. Goal 4. Valid while AGENTS.md → priority 1 holds that a page is a
document.
`.app` is `min-height: 100dvh`. `.nav`'s `overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto`
are not leftovers of a bounded frame: they make the off-canvas panel usable with a long tree.
`#nav-toggle` is `position: fixed` because a label click focuses it, and a browser scrolls a focused
element into view.
## `ICON_NAMES` is a host-owned registry, not a plugin contract
2026-08-05, the maintainer. Goal 7. Valid while the `every icon <use> resolves` e2e test guards the
nav.
`src/ui/icons.ts` is deliberately not re-exported from `@plainpages/plugin-api`. 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. The accepted cost: an unknown sprite id renders blank instead of failing loud.
## Dependencies install to `/node_modules`, above `WORKDIR /app`
2026-08-05, the maintainer. Code health. Valid while dev bind-mounts the checkout at `/app`.
Node resolves upward, so dev's `.:/app` bind mount has nothing to shadow. A volume at
`/app/node_modules` would not do: 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, since it silently
shadows `/node_modules`; `src/compose.test.ts` guards the compose files, and `.dockerignore` the
image.
## A container whose output a human then edits or deletes runs as the invoking user
2026-08-05, the maintainer. Code health. Valid while a dev box may lack `sudo`.
That means `--user "$(id -u):$(id -g)"` for the E2E runner (artifacts) and a lockfile edit;
otherwise the output is root-owned. It is not universal: `bootstrap` writes `jwks.json` as root when
it is absent on first boot. The committed dev key makes that rare, and when it happens the rotation
runbook's host-side `>` needs the file re-owned first. That exception is valid while the dev key
ships committed.
Three consequences:
- `e2e-tests/artifacts/` is tracked (`.gitkeep`), since an absent bind-mount source is
daemon-created as root and that uid then cannot write it (README → Upgrading).
- The runner image sets `HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an
unwritable `/`.
- Rootless Docker wants the flag dropped, container root already being the invoking user.
Baking a `USER` into the image does not work: the image's `pwuser` is 1001, and no fixed uid matches
every host. `src/compose.test.ts` guards every documented command, and `src/ci-gate.test.ts` the
gate's own.
## Anything the browser logs fails the E2E test that provoked it
2026-08-05, the maintainer. Goal 4. Valid while pages ship no JavaScript of their own.
Every spec takes its `test` from `e2e-tests/console-guard.ts`, which fails a test on a console error
or warning, or an 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 accepted cost: 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
2026-08-05, the maintainer. Goal 4. Valid while the Ory-backed specs share one backend.
`visual.spec.ts` and `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,
so widening them means a stack per engine.
## The docs-only CI skip is `*.md` anywhere in the tree
2026-08-05, the maintainer. Code health. Valid while no markdown file is rendered or executed.
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` would otherwise read as docs and skip the gate over a
source file that was gone. `src/ci-gate.test.ts` locks the flags as a text guard, since the test
image ships neither `git` nor `bash`.
Because a `.md` change skips the gate, the Docker Hub overview is
`release-tooling/dockerhub-overview.md.tmpl`. A release reads it and a unit test guards it, so under
a `.md` name a broken `{{VERSION}}` would merge with that guard skipped.
`README.md` is the one markdown file tests read, so a README-only change skips them. That leaves two
gaps:
- An `apiVersion` sample in the README can go stale unchecked by
`release-tooling/contract-version.test.ts`. Accepted, because the README's samples are
illustrative; the copies authors build from (`examples/`, `views/`, the template) are gated.
- A README-only change that drops a command the Hub page shows fails only the next gated change, in
`release-tooling/dockerhub-overview.test.ts`.
## CI docker logins share the runner host's Docker config
2026-07-04, the maintainer. Code health. Valid while releases keep a single-maintainer cadence; if
these races start costing re-runs, serialize the workflows with a `concurrency` group.
The act_runner is host-mode, so `docker login` and `logout` in the workflows mutate one shared
`~/.docker/config.json`. Concurrent jobs can race: one job's logout can 401 another's push, and the
recovery is a re-run. Tokens sit in that file between login and logout. In the same class,
concurrent runs share the workspace dir, so `ci.sh`'s web-image build races another run's container
creation on the `<project>-web` tag.
+17
View File
@@ -423,3 +423,20 @@ Read most (opens and searches):
- `src/plugin-host/hooks.ts` 1
## Reading cost reset, 2026-11-08T19:39:42Z
## Reading cost, chunk 385be0aa8aef, finished 2026-11-08T19:57:05Z
87493 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 19
- `src/plugin-host/plugin.ts` 14
- `src/plugin-host/plugin.test.ts` 9
- `todo.md` 9
- `AGENTS.md` 4
- `docs/ci-cd.md` 4
- `src/compose.test.ts` 2
- `src/ci-gate.test.ts` 1
- `src/function-limits.test.ts` 1
- `src/plugin-host/discovery.ts` 1
+3 -1
View File
@@ -29,7 +29,9 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
// local: generalised, it would make authorization a function of the transport verb (AGENTS.md).
// local: generalised, it would make authorization a function of the transport verb
// (docs/decisions.md → "`actionForMethod` is plugin-local and stays out of
// `@plainpages/plugin-api`").
export function actionForMethod(method: string): AdminAction {
const verb = method.toUpperCase();
return verb === "GET" || verb === "HEAD" ? "read" : "write";
+5 -1
View File
@@ -10,10 +10,14 @@ export interface Gate {
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
}
// A gate naming none of the three allows everyone. Discovery refuses one on a plugin's route, nav
// node or profile-menu entry, so only the header nodes `config/menu.ts`'s `groups` builds get here
// without a gate, and failing closed would hide every section an operator grouped.
export function allows(gate: Gate, user: User | null): boolean {
if (gate.public === true) return true;
if (gate.session === true) return user !== null;
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
if (gate.permission == null) return true;
return user?.permissions.includes(gate.permission) ?? false;
}
export function gatesSet(gate: Gate | null | undefined): string[] {
+2 -1
View File
@@ -110,7 +110,8 @@ test("a one-shot bootstrap seeds the stack before web starts", () => {
});
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).
// A volume at /app/node_modules would leave a root-owned dir in the checkout
// (docs/decisions.md → "Dependencies install to `/node_modules`, above `WORKDIR /app`").
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");
+3 -3
View File
@@ -31,9 +31,9 @@ test("init SQL gives each Ory service its own database, and leaves plugin databa
}
});
// AGENTS.md records that the driver runs the provisioning DDL in bootstrap and nothing else. A
// single value imported from the wrong module puts it in web's graph without changing behaviour,
// so nothing but this would notice.
// A single value imported from the wrong module puts the driver in web's graph without changing
// behaviour, so nothing but this would notice (docs/decisions.md → "Plugin storage hands over
// credentials, not a client").
test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
const files = sourceFiles();
assert.ok(files.length > 40, "walks the source tree");
+2 -1
View File
@@ -20,7 +20,8 @@ import { buildCredentials, storagePluginIds, type StorageCredentials } from "./p
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
// depth, not a boundary (AGENTS.md) — and only ever move this line earlier, never later.
// depth, not a boundary (docs/decisions.md → "The trust boundary is the `web` process, not the
// plugin") — and only ever move this line earlier, never later.
delete process.env["PLUGIN_DB_SECRET"];
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
// per request for access logging + a trace span (src/http/request-tracking.ts); console-only otherwise.
-12
View File
@@ -12,7 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 60 | 0.5.0 | | | **Move the decisions out of AGENTS.md into `docs/decisions.md`, leaving only each decision's title in AGENTS.md.** | 3 | 2 | 2 | 5 | 6 | code health by 4 | 4.0 |
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
| 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 |
@@ -28,17 +27,6 @@
## Details
### 60. Move the decisions out of AGENTS.md into `docs/decisions.md`, leaving only each decision's title in AGENTS.md.
AGENTS.md is loaded into every session, and about 400 of its 474 lines are the "Deliberate
architectural deviations". Each becomes a `docs/decisions.md` entry per `~/.claude/rules/decisions.md`:
a one-line title stating what holds, the reasoning and its "valid while", and the README goal or
principle it serves. AGENTS.md keeps the priorities, Docker commands, README structure, Rules, Comments
and Scoring run, plus the list of titles. Repoint the code comments that cite "(AGENTS.md)" for a
decision, and make `allows` in `src/auth/gate.ts` easier to read: a seat found its reason only in
AGENTS.md. No test reads AGENTS.md; `rank_todo.py` and larv-review start reading `docs/decisions.md`
once it exists, so run `rank_todo.py --check` on it.
### 2. Rename the admin example plugin to a name that says what it administers.
The new name is `auth-admin`, chosen by the maintainer. The id is also the mount path, so the compose files,
+3 -1
View File
@@ -60,7 +60,9 @@
<% if (user.email) { %>
<%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
trigger composes escaped user values and its Sign-out item is a CSRF POST form, neither of
which the partial's Item shapes cover (AGENTS.md). Keep the two in step. %>
which the partial's Item shapes cover (docs/decisions.md → "A dropdown is a
`<button popovertarget>` and a `[popover]`, never a `<details>`"). Keep the two in
step. %>
<div class="menu" style="flex:1 1 auto">
<button class="profile" type="button" popovertarget="profile-menu">
<span class="avatar" aria-hidden="true"><%= user.initials %></span>