Cut non-essential prose from docs and comments, and require the same of every future change
CI / full-gate (push) Successful in 2m38s

README loses the competitor comparison, the personas and the repeated philosophy; the
five near-identical E2E command blocks become a table plus one command, and the file
map a clause per entry. AGENTS.md keeps every decision but drops the narrative around
them. todo.md's completed items collapse to their task line — git holds the rest.

Comments lose restatement, README duplication and history ("used to", "originally",
dated notes). AGENTS.md gains a Prose discipline section making this a standing pass on
every change rather than a one-off cleanup.

src/compose.test.ts now expects 6 documented E2E run commands, not 10, since the README
states the command once instead of per suite.
This commit is contained in:
2026-08-05 23:41:12 +02:00
parent f5240ef7f6
commit a005acb93d
29 changed files with 980 additions and 1639 deletions
+136 -148
View File
@@ -3,20 +3,27 @@
Guidance for AI agents and contributors working in this repo. Read `README.md` for
commands and layout.
## Maintaining this file
## Prose discipline
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.
Every word in this repo is read again on every future task, so prose is a recurring cost. On **any**
change, sweep the prose you touched — this file, `README.md`, the example READMEs, and code
comments — and cut it back to what a competent reader could not infer:
- **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.
- **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.
- **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.
- **Delete the self-evident** and anything already stated once elsewhere. **One home per fact**
link to it instead of repeating it; the same sentence in five files is five chances to drift.
- **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.
- **Keep** the surprising why, the footgun, the invariant, the external constraint, and the one-time
setup a reader cannot dig out of the code. Once a line has earned its place, make it short and
information-dense.
Trimming is not a separate task to schedule — do it in the same change, every time.
## How to work with tasks
@@ -31,29 +38,23 @@ branch, create a PR and merge it when the CI/CD turns green.
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`).
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.
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** 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;
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 `<h1>` per page
+ sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`,
`<button>` vs `<a>`); 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.
4. **Environment-agnostic**no `NODE_ENV` branching. Every behaviour is an **explicit config
toggle** read once in `src/config.ts`; compose files set them per deployment.
5. **Semantic, accessible DOM** — the right element for the job (landmarks, one `<h1>` per page +
sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`, `<button>`
vs `<a>`); ARIA only to fill real gaps. Classes/ids name *meaning*, not looks.
6. **Full, parallel E2E** — every user-facing flow has a Playwright test, shipped in the same change
as the surface. Tests stay independent and side-effect-free so the suite runs `fullyParallel`.
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, and the host
**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)
@@ -62,36 +63,32 @@ Revisit only if the stated reason stops holding.
### 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/`).
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
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 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:
`../../src/*` path. These two barrels are the whole contract surface; 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/<id>/` copies to `plugins/<id>/`,
`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.
- **`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 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.)
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 — keeping
`web` stateless and dependency-light.
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
### Authorization
@@ -100,10 +97,10 @@ Revisit only if the stated reason stops holding.
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; 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.
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
`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
@@ -116,16 +113,16 @@ Revisit only if the stated reason stops holding.
- **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.
- `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 `#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
@@ -133,21 +130,20 @@ Revisit only if the stated reason stops holding.
- **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
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. **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.
- **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.
### i18n
@@ -156,15 +152,14 @@ Revisit only if the stated reason stops holding.
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 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).
- **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
@@ -192,30 +187,28 @@ Revisit only if the stated reason stops holding.
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. Don't convert the CSS or file findings about it on
spec. Valid while no deployment needs an RTL language.
locale needs those moved to logical ones first. Valid while no deployment needs an RTL language.
### 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 no longer clipped by `.table-wrap`'s `overflow`. Four rules hold it
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 (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. `<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 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`; 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 <use> resolves` e2e test catches
anything reaching the nav). Removing an id is a core edit — weigh it per icon rather than sweeping.
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**, because a zero-JS invoker cannot keep the first truthful and the
second would promise `role="menu"` semantics these panels don't implement. `<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 one item is a CSRF POST form) — keep
the two in step.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
is deliberately not re-exported from `#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).
### Build, test & release
@@ -226,16 +219,14 @@ Revisit only if the stated reason stops holding.
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 at all. 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 — which also makes a root-owned leftover
an upgrade hazard (README → Breaking changes). 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 is already the invoking user there. Baking a `USER` in instead
does not work: the image's `pwuser` is 1001, and no fixed uid matches every host.
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
@@ -244,42 +235,38 @@ Revisit only if the stated reason stops holding.
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.
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. 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.
so widening them means a stack per engine.
- **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. 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`. 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 `<project>-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.**
- **Plainpages is pre-announcement: no tags, no releases.** `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. 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`
(its `fetch-tags: true` is load-bearing), so 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 — `checkApiVersion` would refuse a stale plugin by
*version*, but only 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
@@ -298,8 +285,8 @@ 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 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.
stack up and a *minimal* plugin live. Nothing comes before Quick start. 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 →
@@ -308,29 +295,23 @@ docker compose -f compose.yml up --build -d # production
permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
without the model, and it is the one home for that model.
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.
**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.
Keep the ToC in sync when you add/rename/remove an `H2`/`H3`. **Don't document internals** — how a
script reaches a decision, what a function guards; a developer reads that off the code in seconds.
The README earns its length on how to use and operate Plainpages, the external contracts, and
one-time setup. A file-map or table row gets 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` — 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 `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers.
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 with no
comment at all is preferred.
- Do not comment about history ("this moved from X"), or about the absence of things.
- English everywhere.
- 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.
@@ -370,3 +351,10 @@ before adding a row to a table or the file map — a clause, not a paragraph.
- 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`.
## Comments
Default to **no comment**. Delete one that restates the adjacent code, repeats a convention used
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
constraint. See [Prose discipline](#prose-discipline).