Cut non-essential prose from docs and comments, and require the same of every future change #63

Merged
lilleman merged 3 commits from prose-diet into main 2026-08-17 21:08:14 +02:00
30 changed files with 986 additions and 1646 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).
+585 -1069
View File
File diff suppressed because it is too large Load Diff
+7 -14
View File
@@ -1,20 +1,13 @@
import { expect, test } from "./console-guard.ts";
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
// the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=…
// ("Page not found"): the banner printed `localhost` but kratos.yml hard-coded `127.0.0.1`, and a
// host-scoped Kratos CSRF cookie can't cross `localhost`↔`127.0.0.1`, so the cross-host login POST
// lost it and Kratos redirected to its error sink.
// The from-scratch dev experience the banner advertises: `docker compose up`, open the printed
// login URL, sign in as the seeded admin, land on the dashboard. A host-scoped Kratos CSRF cookie
// cannot cross `localhost`↔`127.0.0.1`, so a cross-host login POST loses it and Kratos redirects to
// its error sink; APP_URL canonicalises every off-host visitor onto one cookie host instead.
//
// The fix makes APP_URL the single source for the public host: the web app canonicalises every
// off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos'
// browser URLs derive from it, and a real /error page replaces the 404.
//
// This is faithful to the user's environment: the runner uses the host network
// (e2e-tests/compose.devstack.yml) against the plain `docker compose up` topology, so it sees
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
// The runner is on the host network against the plain `docker compose up` topology, so it sees
// http://localhost:3000 and http://127.0.0.1:4433 exactly as a host browser does. The proxied
// full-flow suite cannot catch this — it fronts web + Kratos on one origin.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
const ADMIN_PASSWORD = "admin";
+2 -2
View File
@@ -50,8 +50,8 @@ test.describe.serial("authenticated admin journey", () => {
});
test.afterAll(async () => { await page.context().close(); });
// The list screens rebuild their query from the list state (sort/page/filter), so they are where
// a chosen language used to get dropped the core building blocks carry it now.
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
// chosen language is most easily dropped; the core building blocks carry it through.
test("a sorted, paged admin list keeps the visitor's language", async () => {
await page.goto("/admin/users?locale=sv-SE");
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
+2 -3
View File
@@ -56,9 +56,8 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
expect(missing).toEqual([]);
});
// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone.)
// The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component and
// exercised live by the full-flow E2E's admin Users list, so it has no Ory-free counterpart here.
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
+5 -6
View File
@@ -1,11 +1,10 @@
// Reference config/menu.ts — copy into the (empty) config/ mount at the repo root:
// Reference config/menu.ts — copy into the empty config/ mount at the repo root:
// cp examples/config/menu.ts config/menu.ts
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
// Absent config = built-in defaults.
//
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
// the override always wins, applied before the per-user permission filter. Every field is
// optional; delete one to fall back to the default.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
// override always wins, applied before the per-user permission filter. Every field is optional.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
import { defineMenu } from "#menu-config";
+17 -23
View File
@@ -1,27 +1,22 @@
# Admin — the system-administration plugin
The Users / Groups / OAuth2-clients screens for running Plainpages itself. These used to be
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
screens live at `/admin/*`) and restart:
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
```bash
cp -r examples/plugins/admin plugins/admin
docker compose up -d
```
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
section appears in the menu and the screens work immediately.
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so
the section appears in the menu and the screens work immediately. An older copy already in
`plugins/` is yours — the host never updates it — so re-copy after a pull; a stale one stops the boot
with a message naming it ([README → Upgrading](../../../README.md#upgrading)).
> **Already have `plugins/admin` from an earlier version?** Re-copy it. Your copy is yours — the host
> never updates it — and this plugin's permissions changed on 2026-08-05 (`admin` → `users:`/`groups:`/
> `oauth2-clients:` × `read`/`write`). A stale copy stops the boot with a message naming it; see
> [README → Upgrading](../../../README.md#upgrading).
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav
labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an
optional `t`; the handlers pass `ctx.t`, and the default is the plugin's own English so a unit test
reads in words rather than keys. (README → [Languages](../../../README.md#languages-i18n).)
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`), the nav
labels included. Each pure view-model builder takes an optional `t` defaulting to the plugin's own
English, so a unit test reads in words rather than keys.
## What it demonstrates — a *system* plugin
@@ -35,15 +30,14 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
gated per route by its screen's `<resource>:<action>` permission, rendering the core building blocks
in `views/`.
`ctx.system` is populated only when the host wired those services. Where a capability is absent the
screen degrades to a themed 503 rather than crashing. Everything else is an ordinary plugin:
folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the
core building blocks in `views/`.
Each screen is its own resource — `users`, `groups`, `oauth2-clients` and each splits into `:read`
and `:write`, so a helpdesk account can be given `users:read` alone. The nav is filtered by the same
permissions: holding none of the three hides the Admin section entirely.
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — split into `:read` and
`:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six hides the
Admin section entirely.
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
+3 -3
View File
@@ -46,9 +46,9 @@ test("buildPermissionPicker ticks what is held and carries each declaration's de
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
});
// The failure this prevents: a permission held through a group used to render unticked, so the page
// said "not held" about a grant that reaches the JWT — and unticking it wrote nothing, which read as
// a successful revoke. Inherited rows are ticked, disabled, and never posted.
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT,
// and unticking it writes nothing, reading as a successful revoke. So inherited rows are ticked,
// disabled, and never posted.
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
+12 -19
View File
@@ -1,14 +1,12 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
// everything imports the host only through the #plugin-api barrel.
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
// Everything imports the host only through the #plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api";
import enUS from "./i18n/en-US.ts";
// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and
// Search), for a view model built outside a request: its unit tests. At runtime the handlers pass
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
// This plugin's English its catalog, then the host's — for a view model built outside a request,
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_USERS_BASE = "/admin/users";
@@ -28,11 +26,9 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
return `${resource}:${action}`;
}
// This plugin's mapping from method to action: every screen reads on GET/HEAD and mutates on POST.
// The manifest's route table and the in-handler guard both go through it rather than each spelling
// the permission out, so they cannot drift into gating on different names. Deliberately local — as
// a general mechanism it would make authorization a function of the transport verb, and a route
// table should answer "what does this need?" on its own (AGENTS.md).
// 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).
export function actionForMethod(method: string): AdminAction {
const verb = method.toUpperCase();
return verb === "GET" || verb === "HEAD" ? "read" : "write";
@@ -54,13 +50,10 @@ export const ADMIN_NAV: NavNode = {
};
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
// declares the same permission, so the host enforces it before the handler runs; this is
// defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the
// handler to thread on. GuardError → /login or 403.
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create form
// or a delete-confirm page, whose only purpose is to start a write. Those refuse a reader honestly
// instead of rendering a form whose submit would 403; the route table declares the same override, so
// the two still cannot disagree.
// declares the same permission, so this is defence-in-depth and what a direct unit test relies on.
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create
// form or a delete-confirm page — which refuses a reader rather than rendering a form whose submit
// would 403. The route table declares the same override, so the two cannot disagree.
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
+4 -5
View File
@@ -1,8 +1,7 @@
// Users admin screen: list Kratos identities (filter/sort/paginate) +
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
// create/edit/deactivate/delete/trigger-recovery. Pure builders turn identities + the request URL
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
// a shared `withUser` gate.
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
@@ -370,7 +369,7 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
// next request. Recovery would be a curl against Keto — not something the operator persona can do.
// next request — leaving a `curl` against Keto as the only way back in.
if (id === user.id && diff.revoke.length > 0) {
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
+4 -6
View File
@@ -1,10 +1,8 @@
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system.
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
//
// It is a *system* plugin: its handlers reach the host's Ory admin clients (Kratos/Keto/Hydra) and the
// instant-revoke hook via ctx.system, which the host populates when those services are wired (the dev
// stack wires all of them). Where a capability is absent the screen degrades to a themed 503.
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
+3 -4
View File
@@ -42,10 +42,9 @@ test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the disco
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
});
// The regression this pins: an earlier revision *threw* here, so `ADMIN_PERMISSIONS=admin` — this
// setting's own default until 2026-08-05 — exited bootstrap 1, and bootstrap gates `web`, so a
// leftover variable bricked the whole stack on upgrade. Bootstrap must never refuse to start over
// operator env: drop what it can't use, report it, seed the rest.
// Bootstrap gates `web`, so it must never refuse to start over operator env — a leftover
// ADMIN_PERMISSIONS would otherwise brick the whole stack. Drop what it can't use, report it, seed
// the rest.
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => {
const legacy = seedPermissions("admin", ["users:read"]);
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
+7 -12
View File
@@ -29,19 +29,14 @@ export function permissionTuple(userId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
}
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
// coarse permission — granted as a Keto `Permission:<name>#granted` tuple). So the host names no plugin, yet a
// dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
// the plugin that gates on it — a host-invented default would gate nothing.
// ADMIN_PERMISSIONS (empty by default) unioned with every discovered plugin's declared names, so
// the host names no plugin yet a dropped-in one is seeded out of the box.
//
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
// same `<resource>:<action>` rule discovery applies to a manifest — but *dropped with a warning*,
// never fatal. Fail-loud belongs at the manifest boundary, where a developer authored the mistake
// and can fix it; this is operator env, bootstrap gates `web`, and the whole stack must not refuse
// to start over a stale variable. `admin` was this setting's own default before 2026-08-05, so a
// value that bricks the boot is the *expected* leftover on any upgrade. The name it would have
// written gates nothing anyway. Declared names already passed the check at discovery.
// same `<resource>:<action>` rule as a manifest — but *dropped with a warning*, never fatal:
// fail-loud belongs at the manifest boundary where a developer authored the mistake, whereas this
// is operator env and bootstrap gates `web`, so the whole stack must not refuse to start over a
// stale variable. The name it would have written gates nothing anyway.
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
const configured = clean((adminPermissionsEnv ?? "").split(","));
+8 -16
View File
@@ -1,20 +1,12 @@
// Optional revocation denylist: instant permission/session revoke without putting Keto
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
// Optional revocation denylist: instant permission/session revoke without putting Keto back on the
// hot path. Off by default — enable with REVOCATION_DENYLIST=true. An admin action records the
// subject as revoked-now; the hot path then rejects that subject's pre-revoke tokens at once,
// forcing a re-mint (which re-reads permissions from Keto, or clears a now-dead session).
//
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
// account) that lag is too long. An admin action records the subject as revoked-now and the
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
// re-reads permissions from Keto, or clears a now-dead session).
//
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
// the revoke) passes while every token minted before the revoke is rejected. Entries self-evict
// after one token TTL, by which point any pre-revoke token has expired anyway. Single-process:
// instant on the instance that handled the revoke; across replicas/restarts the guarantee
// falls back to the token TTL (the gap is just no longer closed early). Back it with a shared
// store for hard multi-instance instant-revoke.
// An in-memory, auto-evicting Map — no database, so it stays inside the stateless model. Entries
// self-evict after one token TTL, by which point any pre-revoke token has expired anyway.
// Single-process: instant on the instance that handled the revoke, elsewhere the guarantee falls
// back to the token TTL. Back it with a shared store for hard multi-instance instant-revoke.
export interface Denylist {
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
+3 -5
View File
@@ -1,8 +1,6 @@
// Auth guards: in-handler authorization, the imperative counterpart to the
// declarative route `permission` gate. The middleware already verified the session JWT and put
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
// In-handler authorization, the imperative counterpart to the declarative route `permission` gate.
// `requireSession` asserts (throws GuardError, which app.ts maps to a response); `can`/`check` are
// predicates a handler branches on. `check` is the one live Keto call, for relationship rules.
import type { RequestContext, User } from "../http/context.ts";
import type { KetoClient } from "./keto-client.ts";
import { localPath } from "../http/safe-url.ts";
+2 -4
View File
@@ -231,10 +231,8 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
}
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
// path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The
// canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the
// honest fallback for any genuine flow error. The id is shown only for support reference.
// security/expiry check redirects the browser here with ?id=<uuid>; render a themed page with a
// path back into sign-in rather than the catch-all 404. The id is shown for support reference only.
const errorSink = (ctx: RequestContext): RouteResult =>
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
+1 -1
View File
@@ -113,7 +113,7 @@ test("the E2E runner writes its artifacts as the invoking user, never as root",
// filter would otherwise leave that command silently unguarded.
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
assert.equal(documented.length, 10, "5 compose headers + 5 README blocks");
assert.equal(documented.length, 6, "5 compose headers + 1 README block");
for (const l of documented)
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
+3 -9
View File
@@ -1,12 +1,6 @@
// Config loaded once from the environment at boot: Ory endpoints, cookie/CSRF
// secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a
// missing enforced secret, a bad URL, or an out-of-range port throws here, never at
// request time.
//
// Environment-agnostic (AGENTS.md): the app never asks "which environment am I?". Every
// behaviour that used to ride on NODE_ENV is its own explicit toggle — `CACHE_TEMPLATES`,
// `REQUIRE_SECURE_SECRETS`. Clean-clone (README): every value has a working dev default,
// so `docker compose up` runs with zero config; a hardened deploy sets the toggles it wants.
// Config loaded once from the environment at boot. Fail-loud — a bad value, a missing enforced
// secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a
// working dev default, so `docker compose up` runs with zero config.
// Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels).
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
+11 -17
View File
@@ -27,15 +27,12 @@ import type { MenuConfig } from "../ui/menu-config.ts";
import { loadI18n } from "../i18n/load.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
// createApp (with stub Ory clients on ctx.system + views from examples/plugins) exactly as an
// operator would after copying it into plugins/.
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
// ctx.system, views from examples/plugins exactly as an operator would after copying it in.
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
// A session JWT signed with a throwaway test key — the verify path. Wired into the shared
// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the
// gated routes need one. `staticJwks([ecJwk])` is the matching verify side.
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
@@ -101,8 +98,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
mkdirSync(join(dir, "portal", "views"), { recursive: true });
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
// The dashboard view renders the native app shell from ctx.chrome — the blessed plugin ergonomics:
// its own title/body, the global menu (chrome.nav), the signed-in user, the Sign-out CSRF token.
// The dashboard view renders the native app shell from ctx.chrome.
writeFileSync(join(dir, "portal", "views", "board.ejs"),
`<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
t.after(() => rmSync(dir, { force: true, recursive: true }));
@@ -326,9 +322,8 @@ function rawGet(port: number, path: string, host: string, method = "GET"): Promi
}
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
// The fix for the localhost-vs-127.0.0.1 / multi-domain trap: reach the app on any host and it
// sends you to APP_URL's host, so the browser, the themed form, and the cross-origin Kratos POST
// all share ONE cookie host. Off-canonical only — same-host requests pass straight through.
// Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the
// cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through.
const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
@@ -359,8 +354,8 @@ test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-a
});
test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => {
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>. Without a
// handler it 404'd as "Page not found" (confusing). It must be a real, themed page now.
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>, which must
// land on a real themed page rather than the catch-all 404.
const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" });
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
@@ -1257,9 +1252,8 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
});
// Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen.
// The offered set is the host's catalog (ctx.declaredPermissions, from what the installed plugins
// declare), so the checkboxes are a fixed list and the POST is the desired state.
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
// so the checkboxes are a fixed list and the POST is the desired state.
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
@@ -1348,7 +1342,7 @@ test("admin screens render no write affordance for a read-only holder", async (t
assert.doesNotMatch(group, /Delete group/);
assert.doesNotMatch(group, /Save permissions/);
// The OAuth2-clients screen is held to the same rule (it was the one this test was written to catch).
// The OAuth2-clients screen is held to the same rule.
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
const clients = await clientsRes.text();
+43 -96
View File
@@ -39,15 +39,11 @@ const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export interface AppOptions {
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
// Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean;
cache?: boolean; // cache compiled EJS templates (config.cacheTemplates); off ⇒ edits show live
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
// Loaded translation catalogs (server.ts passes the discovered ones). Omitted ⇒ the built-in
// en-US catalog only, so an unwired app still renders real English.
i18n?: I18n;
i18n?: I18n; // discovered catalogs; omitted ⇒ the built-in en-US only, so an unwired app still renders English
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
@@ -62,15 +58,12 @@ export interface AppOptions {
}
export function createApp(options: AppOptions = {}): Server {
// The denylist (when enabled) rides in the verify options so resolveSession rejects a revoked
// subject on the hot path; the bound `revoke` is handed to the admin handlers that should
// revoke instantly. Both absent ⇒ the feature is fully off (no cost, no behaviour change).
// The denylist rides in the verify options so resolveSession rejects a revoked subject on the hot
// path; the bound `revoke` goes to the admin handlers. Both absent ⇒ the feature is fully off.
const denylist = options.denylist;
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
const cache = options.cache ?? false;
// Canonical public host (APP_URL): when set, an off-host GET/HEAD visitor is redirected here so
// every cookie (esp. Kratos' cross-origin CSRF cookie) shares one host. Omitted ⇒ feature off.
const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
@@ -82,9 +75,7 @@ export function createApp(options: AppOptions = {}): Server {
const keto = options.keto;
const kratos = options.kratos;
const kratosAdmin = options.kratosAdmin;
// Privileged host services handed to a system plugin via ctx.system — the Ory admin clients and
// the instant-revoke hook. Only the wired capabilities are present; with none wired ctx.system
// stays undefined, so an ordinary deployment (no Ory, hence no system plugin) pays nothing.
// Only the wired capabilities are present; with none wired ctx.system stays undefined.
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
: undefined;
@@ -93,15 +84,11 @@ export function createApp(options: AppOptions = {}): Server {
const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id));
// A plugin may fully replace the public landing "/" (`home`) or the gated dashboard "/dashboard"
// (`dashboard`) — Discovery's findConflicts guarantees at most one of each, so `find` is
// unambiguous; the predicates narrow the slot to defined.
// `find` is unambiguous: findConflicts guarantees at most one owner of each landing slot.
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
// The permission catalog is a property of the installed plugin set, so it is computed once at
// wiring rather than per request.
const permissionCatalog = declaredPermissions(plugins);
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
@@ -115,19 +102,11 @@ export function createApp(options: AppOptions = {}): Server {
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
// A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) merged
// in, so a view — core or plugin, at any include depth — calls `t(...)` without its handler passing
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
// happens to use one loses that key rather than breaking the shell that renders around it.
// Where the language picker on this page should point. Normally the page itself; after a POST
// that URL may answer no GET (POST /admin/users/:id/delete has no GET sibling), so fall back to
// the page the form was submitted from, then to the front page — the picker is on every page, so
// every one of its links has to land somewhere real.
// Where the language picker points. Normally the page itself; after a POST that URL may answer no
// GET (POST /admin/users/:id/delete has no GET sibling), so fall back to the page the form was
// submitted from, then to the front page — the picker is on every page, so every link must land.
const switchBase = (req: IncomingMessage, url: URL): string => {
const method = (req.method ?? "GET").toUpperCase();
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
@@ -147,6 +126,8 @@ export function createApp(options: AppOptions = {}): Server {
t: ctx.t,
url: ctx.url,
});
// i18n locals go last: their names are reserved, so a handler's colliding key loses instead of
// breaking the shell around it.
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
@@ -155,10 +136,7 @@ export function createApp(options: AppOptions = {}): Server {
res.end(html);
};
// The public landing "/": ungated — anyone may see it. A plugin may fully own it via `home`
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
// any form it ships). Else the built-in intro page with prominent sign-in / register links
// (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie).
// The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
csrf.setCookie();
if (homePlugin) {
@@ -172,10 +150,8 @@ export function createApp(options: AppOptions = {}): Server {
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
};
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign
// in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its
// handler renders against its own views, same path as a plugin route. Else the built-in
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
// starter page.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
@@ -214,26 +190,21 @@ export function createApp(options: AppOptions = {}): Server {
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
// /public/<id>/… serves a plugin's public/; everything else the core public/.
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
return;
}
// Rendered pages content-negotiate on Accept-Language, so a cache in front of us must key on
// it — otherwise the first visitor's language is served to everyone. Set after the static
// branch above: an asset is the same bytes in every language, and a Vary there would fragment
// its cache entry per raw header string.
// A cache in front of us must key on the language. Set after the static branch: an asset is
// the same bytes in every language, and a Vary there fragments its entry per raw header.
res.setHeader("vary", "accept-language");
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
// otherwise the host-scoped Kratos CSRF cookie is lost and login dumps onto /error. Static
// assets above are served on any host (health checks). GET/HEAD only — a 308 must not replay a
// cross-host POST; first-party forms are always served from a canonical page anyway.
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
// host-scoped Kratos CSRF cookie is lost and login dumps onto /error. GET/HEAD only: a 308
// must not replay a cross-host POST.
if (canonicalHost && (method === "GET" || method === "HEAD")) {
const host = req.headers.host;
if (host !== undefined && host !== canonicalHost) {
@@ -242,18 +213,14 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// Which language this request is served in: ?locale wins, else Accept-Language, else en-US.
// `explicit` (the URL asked) is what makes the choice travel: the chrome, this request's
// redirects and ctx.localeHref then carry ?locale onto the links they emit.
// `explicit` (the URL asked for a locale) is what makes the choice travel: the chrome, this
// request's redirects and ctx.localeHref then carry ?locale onto the links they emit.
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
const t = i18n.translator(locale);
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
// in". The only place the hot path touches Ory.
let user: User | null = null;
if (jwks) {
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
@@ -264,32 +231,25 @@ export function createApp(options: AppOptions = {}): Server {
user = reminted.user;
res.appendHeader("set-cookie", reminted.setCookie);
} catch (err) {
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
// 500ing every lapsed request. Leave the cookie alone: it can re-mint once Ory recovers.
// Ory unreachable — degrade to anonymous instead of 500ing every lapsed request. Leave
// the cookie alone: it can re-mint once Ory recovers.
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
}
}
}
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
// one (a page-emitting handler Set-Cookies it via csrfMint). Verified on our own
// state-changing routes.
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
const csrfMint: RequestCsrf = {
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
token: csrf.token,
};
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
// Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and
// at most once per request: this app-level memo shares it across the contexts below, and each
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
// or the public "/" with a standalone home, never composes the menu).
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
// handler, or the public "/" with a standalone home, never pays for it.
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
// The i18n half of every context: the locale, its translator, and the link carrier. A plugin
// route swaps in the plugin's own translator (its catalog first, then core).
// A plugin's context gets the plugin's own translator — its catalog first, then core.
const i18nFor = (pluginId?: string) => ({
locale,
localeHref: carryLocale,
@@ -297,9 +257,8 @@ export function createApp(options: AppOptions = {}): Server {
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
});
// base context (no route params yet); reused for the built-in routes. A plugin-owned render
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
// own catalog is what `ctx.t` reads.
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
// landing slot, a hook short-circuit, a plugin route gets `contextFor(id)` instead.
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
@@ -309,23 +268,19 @@ export function createApp(options: AppOptions = {}): Server {
if (anyRequestHooks) {
const short = await runRequestHooks(plugins, contextFor);
if (short) {
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
// Like every other page-emitting path, so a form the hook renders has its matching cookie.
csrfMint.setCookie();
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
return;
}
}
// Plugin routes (any method): gate on the route's permission, then run the handler. The
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = contextFor(match.plugin.id, match.params);
if (!isAuthorized(match.route, routeCtx.permissions)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
// lacks the permission gets the 403 page.
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await renderPage("403", {}));
@@ -340,9 +295,6 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Built-in endpoints (the auth/OAuth2 group, the landing slots, /error) from the internal
// route table — same handler shape as plugin routes; a `view` result renders the core views,
// null means the handler wrote to ctx.res itself.
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) {
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
@@ -385,20 +337,16 @@ export function createApp(options: AppOptions = {}): Server {
};
return createServer((req, res) => {
// Per-request log + trace span: a "request" span, continuing an upstream W3C traceparent
// when present (distributed tracing across a proxy). "close" (not "finish") fires on both a
// completed response and a premature disconnect/abort, so an aborted/truncated request is still
// logged and its span flushed.
// "close" (not "finish") fires on both a completed response and a premature disconnect, so an
// aborted request is still logged and its span flushed.
const startMs = Date.now();
const reqLog = requestLogger(log, {
requestId: randomUUID(),
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
});
// end() must run exactly once, after BOTH the handler has fully unwound (settled) AND the
// response has closed (the access line is then emitted with the final status). Ending earlier
// would throw "already ended" from a still-running handler's ctx.log/tracedFetch on a client
// abort, or drop the access line on the happy path (handler settles before close). Coordinating
// the two signals avoids both. Logging must never crash a served request, so it's all guarded.
// end() must run exactly once, after BOTH the handler has unwound AND the response has closed.
// Earlier would throw "already ended" from a still-running handler's ctx.log on a client abort,
// or drop the access line on the happy path (the handler settles before close).
let settled = false;
let closed = false;
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
@@ -410,9 +358,8 @@ export function createApp(options: AppOptions = {}): Server {
} catch { /* never let logging crash a served request */ }
finalize();
});
// Make reqLog ambient for the whole handler (sync body + every await) so all outbound fetch is
// traced. handleRequest owns its own try/catch; the .catch logs a pathological escape via the
// app logger (not reqLog, which may be the thing that broke), never crashing the request.
// Make reqLog ambient for the whole handler so all outbound fetch is traced. The .catch logs a
// pathological escape via the app logger — not reqLog, which may be the thing that broke.
void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
.finally(() => { settled = true; finalize(); });
+4 -11
View File
@@ -1,15 +1,8 @@
// URL safety helpers. Two pure, dependency-free guards:
//
// safeUrl(value) — sanitise an untrusted URL before rendering it in an href/src attribute.
// Partials escape *text*, but a URL field is emitted verbatim, so a
// `javascript:`/`data:` URL from upstream/user data would be live XSS. The
// contract (README.md → Routes & handlers) is: a relative or http(s) URL is allowed,
// safeUrl(value) — a URL field is emitted verbatim into an href/src, so a `javascript:`/`data:`
// URL from untrusted data would be live XSS. Relative or http(s) passes,
// anything else collapses to "#". Exported to plugins via plugin-api.ts.
//
// localPath(value) — validate a redirect target is a *same-origin* path (the redirect-URI
// allowlist). Used for `return_to`: a host-relative "/a/b?x=1" passes, an
// absolute or protocol-relative ("//evil.com", "https://evil.com") is rejected
// so a crafted ?return_to= can't turn login completion into an open redirect.
// localPath(value) — the redirect-URI allowlist for `return_to`: host-relative passes, absolute
// or protocol-relative is rejected, so a crafted value can't open-redirect.
// ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before
// the scheme check so "java\tscript:" / a leading space can't masquerade as relative.
+7 -11
View File
@@ -1,15 +1,11 @@
// Response security headers: set once per request in app.ts so every response — page,
// JSON, redirect, static, or error — carries them (writeHead merges with setHeader). A plugin route
// may override any of them per-response via RouteResult.headers (e.g. relax the CSP to ship its own JS).
// Set once per request in app.ts, so every response carries them (writeHead merges with setHeader).
// A plugin route may override any per-response via RouteResult.headers.
// Strict default CSP for the zero-JS, server-rendered core:
// - script-src 'self' : the core ships no JS; a plugin may still serve its own /public/<id>/*.js for
// opt-in progressive enhancement. No 'unsafe-inline' ⇒ an injected <script>
// can't run (the main XSS sink).
// - style-src adds 'unsafe-inline' : a few partials carry inline style= attributes.
// - img-src adds data: : favicon + inline data URIs.
// - no form-action : the themed login form posts to Kratos' (often cross-origin) action URL.
// - frame-ancestors 'none' : clickjacking guard (the modern X-Frame-Options).
// The non-obvious parts of the CSP:
// - script-src 'self' with no 'unsafe-inline' ⇒ an injected <script> can't run. A plugin may still
// serve its own /public/<id>/*.js for opt-in progressive enhancement.
// - style-src adds 'unsafe-inline': a few partials carry inline style= attributes.
// - no form-action: the themed login form posts to Kratos' (often cross-origin) action URL.
const CSP = [
"base-uri 'self'",
"default-src 'self'",
+6 -8
View File
@@ -1,12 +1,10 @@
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then
// check every one against its set's en-US baseline. The imperative shell over catalog.ts's pure
// rules — the same contract as plugin discovery: one boot-stopping Error listing every problem,
// so a half-translated deploy is caught at startup rather than as a stray English word in production.
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then check
// every one against its set's en-US baseline. The imperative shell over catalog.ts's pure rules,
// with plugin discovery's contract: one boot-stopping Error listing every problem, so a
// half-translated deploy is caught at startup rather than as a stray English word in production.
//
// Installed locales are whatever the core folder holds; a plugin may translate fewer of them (its
// strings then render in en-US on that page) but never one the host does not have. The operator's
// `locales/` mount extends both sides — `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts`
// for a plugin — so adding a language never means forking the image or a vendored plugin.
// A plugin may translate fewer locales than the core holds (its strings then render in en-US) but
// never one the host lacks. The operator's `locales/` mount extends both sides.
import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
+6 -9
View File
@@ -1,12 +1,9 @@
// The translator: a key + vars → the string to render. Pure and synchronous — views call it
// as `t("shell.signOut")` and handlers as `ctx.t(...)`.
//
// Two rules the rest of the app leans on:
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US) and,
// when nothing has the key, returns the key itself. That is what makes a plain nav label like
// "Shifts" its own fallback — a manifest needs no catalog to keep working.
// · the result is raw text. Views escape with <%= %> exactly as they do for any other value, so a
// translation is never double-escaped, and a message that carries markup is rendered with <%- %>.
// The translator: a key + vars → the string to render. Two rules the rest of the app leans on:
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US)
// and returns the key itself when nothing has it — so a plain nav label like "Shifts" is its
// own fallback and a manifest needs no catalog to keep working.
// · the result is raw text, escaped by the view with <%= %> like any other value, so a
// translation is never double-escaped and one carrying markup is rendered with <%- %>.
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
+10 -17
View File
@@ -21,11 +21,9 @@ export interface LoggerOptions {
stdout?: (msg: string) => void;
}
// The app-level logger: a Log tagged service.name so every console line, OTLP log record and span is
// attributed to the service. Level + format + name are explicit toggles (LOG_LEVEL/LOG_FORMAT/
// SERVICE_NAME — environment-agnostic, AGENTS.md §4). With otlpEndpoint set, logs + spans also export
// to that OTLP/HTTP collector (e.g. an OpenTelemetry Collector fronting Tempo/Loki); unset ⇒ console
// only, at zero export cost. Conditional spreads keep exactOptionalPropertyTypes happy (no `key: undefined`).
// The app-level logger, tagged service.name. With otlpEndpoint set, logs + spans also export to that
// OTLP/HTTP collector; unset ⇒ console only, at zero export cost. The conditional spreads keep
// exactOptionalPropertyTypes happy (no `key: undefined`).
export function createLogger(opts: LoggerOptions = {}): Log {
return new Log({
context: { "service.name": opts.serviceName || SERVICE_NAME },
@@ -49,13 +47,10 @@ export function currentLog(): Log | undefined {
return requestStore.getStore();
}
// A drop-in `fetch` that traces through the active request log — a client span nested under the
// request span, with a W3C `traceparent` injected so the downstream service continues the same
// trace. Outside a request (no ambient log) or for a non-string/URL input it's a plain `fetch`.
// server.ts wires this (under the Ory timeout) into every Kratos/Keto/Hydra/JWKS call; a plugin
// uses it for its upstream calls (exported via plugin-api.ts). The trace-setup adds no throw of its
// own, but log.fetch throws synchronously if the request log has already ended (app.ts ends it only
// after the handler unwinds, so a live handler never hits that).
// A drop-in `fetch` that traces through the active request log — a client span under the request
// span, with a W3C `traceparent` injected so the downstream service continues the same trace.
// Outside a request, or for a non-string/URL input, it is a plain `fetch`. Note log.fetch throws
// synchronously once the request log has ended; app.ts ends it only after the handler unwinds.
export const tracedFetch: typeof fetch = (input, init) => {
const log = currentLog();
if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init);
@@ -63,11 +58,9 @@ export const tracedFetch: typeof fetch = (input, init) => {
};
// A per-request child logger holding a "request" trace span. `clone` (not parentLog) gives the
// request its own root trace so requests aren't all nested under one app-lifetime span while
// inheriting the parent's level/format/streams/OTLP. A valid upstream W3C `traceparent` is adopted
// (the span continues that distributed trace across a reverse proxy/gateway; malformed ⇒ ignored, a
// fresh trace starts). `requestId` tags every line + the span for log↔trace correlation. Flush with
// `end()` on response finish to export the span — a no-op when OTLP is off.
// request its own root trace, so requests aren't all nested under one app-lifetime span, while
// inheriting the parent's level/format/streams/OTLP. A valid upstream `traceparent` is adopted;
// malformed ⇒ ignored, a fresh trace starts. `end()` on response finish exports the span.
export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log {
return appLog.clone({
context: { ...appLog.context, requestId: opts.requestId },
+27 -43
View File
@@ -1,6 +1,5 @@
// The plugin contract — the product's main API surface: the machine-readable types +
// pure rules; README.md (Building plugins) is the prose reference, discovery/router wire it to FS+HTTP.
// Powerful, predictable, fails loud at boot/discovery rather than sandboxing at runtime.
// The plugin contract — the product's main API surface: the machine-readable types + pure rules.
// READMEBuilding plugins is the prose reference; discovery/router wire this to FS + HTTP.
//
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
@@ -8,9 +7,7 @@
import type { RequestContext } from "../http/context.ts";
import type { NavNode } from "../ui/nav.ts";
// Host contract version (semver). Bump major on a breaking manifest/handler change, minor on an
// additive one. A plugin pins the version it targets via `apiVersion`; the host applies
// provider/consumer semver semantics in checkApiVersion (refuse/warn on mismatch).
// Bump major on a breaking manifest/handler change, minor on an additive one.
export const HOST_API_VERSION = "1.0.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
@@ -30,24 +27,21 @@ export interface Route {
method: HttpMethod;
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
// — an ungated route is already open — but stated outright, so "public" is a deliberate
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
public?: boolean;
}
// A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
// global namespace (so an operator grants them once in Keto) and are always `<resource>:<action>` —
// `scheduling:read`, `users:write`. A bare word names who someone is rather than what they may do,
// which is a role, and roles are groups here (README → Users, groups & permissions).
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
// namespace, so an operator grants them once in Keto. See README → Users, groups & permissions.
export interface PermissionDecl {
description?: string;
name: string;
}
// `<resource>:<action>`, each half lowercase alphanumeric with dashes/underscores inside. The 64-char
// cap keeps a name usable as a Keto object and a URL path segment. Enforced at discovery like every
// other manifest rule, so the convention holds for plugins the admin GUI never touches.
// `<resource>:<action>`. The 64-char cap keeps a name usable as a Keto object and a URL path
// segment. Enforced at discovery like every other manifest rule, so the convention holds for
// plugins the admin GUI never touches.
const PERMISSION_NAME = /^[a-z0-9][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/;
export function isValidPermissionName(name: string): boolean {
@@ -77,12 +71,10 @@ export interface PluginHooks {
// host derives them from the folder name at discovery (see Plugin).
export interface PluginManifest {
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
// Take over the gated dashboard "/dashboard" the post-login app home. A handler like any
// route's; the host gates it to a signed-in session (anonymous → /login), then renders its own view
// via ctx.chrome. At most one plugin may declare it (findConflicts → error, never last-write-wins).
// Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may
// declare it (findConflicts → error, never last-write-wins).
dashboard?: RouteHandler;
// Take over the public landing "/" — the ungated front page. A handler like any route's,
// anyone may reach it. At most one plugin may declare it (findConflicts → error).
// Take over the ungated public landing "/". At most one plugin may declare it.
home?: RouteHandler;
hooks?: PluginHooks;
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
@@ -96,27 +88,23 @@ export interface Plugin extends PluginManifest {
id: string;
}
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
//, so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
// Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may
// equally be a plain typed object.
export function definePlugin(manifest: PluginManifest): PluginManifest {
return manifest;
}
// A plugin id (its folder name) — lowercase az, digits, and dashes, dashes allowed anywhere.
// Rejects uppercase, underscores, dots, slashes, spaces: the id forms the mount path `/<id>`,
// the view/static namespace, and the central-override target, so it must stay URL/path-safe.
// The id forms the mount path `/<id>`, the view/static namespace and the central-override target,
// so it must stay URL/path-safe: no uppercase, underscores, dots, slashes or spaces.
const PLUGIN_ID = /^[a-z0-9-]+$/;
export function isValidPluginId(id: string): boolean {
return PLUGIN_ID.test(id);
}
// Ids the host reserves for its own first-party mount segments (the gated /dashboard, the auth flows,
// /auth/complete, /logout, the /oauth2 provider routes, the /public/ static). Plugin routes resolve
// before these, so a folder named one of them would silently shadow a built-in route — discovery
// refuses it, loud like any conflict. ("/" is owned by the `home` field, not a route, so it can't be
// shadowed and needs no reservation.) Note `admin` is NOT reserved: the admin screens ship as a
// drop-in plugin (examples/plugins/admin, mounted at /admin), not a built-in route.
// Plugin routes resolve before the built-ins, so a folder named one of these would silently shadow
// one — discovery refuses it. "/" is owned by the `home` field, not a route, so it needs no
// reservation; `admin` is deliberately absent, the admin screens being a drop-in plugin.
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
"auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
]);
@@ -127,14 +115,12 @@ export interface Semver {
patch: number;
}
// The official semver.org 2.0.0 core regex (major.minor.patch, optional prerelease/build) — a
// standardized parse with no dependency. We compare only major/minor for compatibility, so the
// prerelease/build groups are matched (to accept valid input) but otherwise ignored.
// The official semver.org 2.0.0 core regex. Only major/minor drive compatibility, so the
// prerelease/build groups are matched to accept valid input but otherwise ignored.
const SEMVER =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
// Parse a strict semver string → {major, minor, patch}, or null. Rejects ranges/prefixes
// (`^1.2.3`, `v1`), leading zeros, whitespace and missing parts — fail loud over coerce.
// Rejects ranges/prefixes (`^1.2.3`, `v1`), leading zeros and missing parts — fail loud over coerce.
export function parseSemver(version: unknown): Semver | null {
if (typeof version !== "string") return null;
const m = SEMVER.exec(version);
@@ -147,9 +133,8 @@ export interface VersionCheck {
message: string;
}
// Provider/consumer semver check (full table in README.md → Contract versioning): same major+minor → ok,
// plugin minor < host → warn, else (newer minor, major mismatch, malformed) → refuse. Patch is
// ignored. Discovery maps refuse→throw, warn→log.
// Provider/consumer semver check (full table in README → Contract versioning). Discovery maps
// refuse→throw, warn→log.
export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HOST_API_VERSION): VersionCheck {
const plugin = parseSemver(pluginVersion);
const host = parseSemver(hostVersion);
@@ -176,9 +161,8 @@ export interface PluginConflict {
plugins: string[]; // unique ids involved
}
// The conflict rules: defined, loud resolution never last-write-wins. Pure over the discovered
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
// is structural — it follows from the id check, so it needs no rule of its own. Shared permission
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
// names are the one intentional overlap, so they warn rather than error.
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
const out: PluginConflict[] = [];
+4 -5
View File
@@ -10,11 +10,10 @@ import type { HydraAdmin } from "../auth/hydra-admin.ts";
import type { KetoClient } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
// Grouping criterion (keep this cohesive — it's a contract, so the "no catch-all bucket" rule that
// governs folders governs this bag too): every field is a *privileged, host-owned, wire-dependent*
// capability for administering Plainpages' own identity/permission stack. Add a field only when it
// meets all three; if unrelated privileged concerns accrete (mailer, metrics, flags), sub-group
// rather than pile them in flat.
// Keep this cohesive — it is a contract, so the "no catch-all bucket" rule applies: every field is a
// *privileged, host-owned, wire-dependent* capability for administering Plainpages' own
// identity/permission stack. Add one only when it meets all three; sub-group rather than pile in
// unrelated privileged concerns (mailer, metrics, flags).
export interface SystemCapabilities {
hydra?: HydraAdmin; // OAuth2 client admin (Hydra); present when the Hydra admin client is wired
keto?: KetoClient; // relationship read/write (Keto); present when Keto is wired
+6 -14
View File
@@ -1,9 +1,6 @@
// Page chrome for plugin pages: the brand / global-nav / user / theme / csrf block a
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
// every plugin renders. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
// nav is the global menu — Dashboard + every plugin's fragment (admin screens included, when the
// admin plugin is installed) — run through composeNav (override + per-user filter) and
// current-marked for the request path.
// The brand / global-nav / user / theme / csrf block a view hands to partials/shell, exposed on
// ctx.chrome. `nav` is the global menu — Dashboard plus every plugin's fragment — run through
// composeNav (override + per-user filter) and current-marked for the request path.
import type { User } from "../http/context.ts";
import { ENGLISH } from "../i18n/english.ts";
@@ -13,9 +10,6 @@ import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
// The "Dashboard" link to the gated app home (/dashboard). It targets a gated route, so it's shown
// only to a signed-in user (an anonymous click would only dead-end at /login). Its label is a
// catalog key — composeNav translates every label, and an unknown one renders as written.
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
export interface PageChrome {
@@ -41,13 +35,11 @@ export interface ChromeOptions {
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
const t = opts.t ?? ENGLISH;
const carryLocale = opts.localeHref ?? ((href: string) => href);
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
// Dashboard is gated, so an anonymous click would only dead-end at /login.
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
// translator before they are merged. composeNav then runs the core one over the result for the
// built-in nodes and the central override's labels; already-translated text passes through it.
// translator before merging. composeNav then runs the core one over the result; already-translated
// text passes through it.
for (const p of opts.plugins ?? []) {
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
}
+4 -7
View File
@@ -1,10 +1,7 @@
// composeNav: merge each plugin's nav fragment into one tree, apply the central
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
// `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
// the override (+ branding); this helper only transforms data, so its result is per-deployment
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
// permission-filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim,
// never Keto. A node is visible iff it is `public`, declares no `permission`, or the user holds that
// name; a gated header hides its whole subtree, and a pure header left with no children is dropped.
import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
+53 -58
View File
@@ -4,71 +4,66 @@
- [ ] Add a way to configure plugins directly when installing. Most reasonable is an .env file in the plugin folder, I think, but I am open to suggestions.
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
- [ ] Guard the group paths to self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Same scope the deleted Permissions screen had, and recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query. Raised by the stability review 2026-08-05.
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change (standard lost-update on a set-based form — and the natural "two of us are onboarding the new hire" workflow produces exactly it). Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying. Fits the existing "the form is the whole truth" model instead of fighting it. Raised by the product review 2026-08-05.
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name (so an unrelated save can't drop it), but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action. Raised by the product review 2026-08-05.
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose (mandatory declaration would warn on the legitimate cross-plugin sharing case). The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error, no warning, and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission. Raised by the product review 2026-08-05.
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a *successful* save is indistinguishable from "nothing changed" (PRG back to the same page, checkboxes as the only feedback). The `alert alert-pos` pattern the recovery-code banner uses is already available. Raised by the product review 2026-08-05.
- [ ] Add the read-only operator to README → Overview's personas. `users:read` now makes a support/helpdesk account possible for the first time, and it is a distinct persona from the three listed (end user, non-technical user, plugin author) — the one whose screens must render without write affordances. Writing it down makes read-only rendering a stated requirement rather than something the next reviewer rediscovers. Raised by the product review 2026-08-05.
- [ ] The seeded admin@plainpages.local are assigned twice to the permission "admin", should only be one, right? (the "admin" permission name can be switched after previous todos have been done)
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name, but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action.
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose. The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission.
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed". The `alert alert-pos` pattern the recovery-code banner uses is already available.
- [ ] The seeded admin@plainpages.local is assigned twice to the same permission; should only be once.
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks the most logic-bearing file in it (`console-guard.ts`) — Playwright strips its types without checking them. Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither. Raised by review 2026-08-05.
- [ ] Decide whether Playwright's `workers` should be pinned. It is unset, so Playwright sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate rather than a retry. Fine on the current act_runner; revisit if CI ever runs constrained. Raised by review 2026-08-05.
- [ ] Record the browser floor Plainpages actually requires, and whether the fallback is the contract or a courtesy. The stylesheet already needs `:has()` (Dec 2023); the menus now need the popover API (Safari 17, Sep 2023) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet capped at Safari 16, and exactly the "tablet on a factory floor, old thin client at a reception desk" README → Overview sells the zero-JS stance on — therefore gets panels flowing inline rather than working menus. Either state a supported floor in the README or accept the fallback as the answer for those devices; nobody has rendered that path on real hardware. Raised by the architecture review 2026-08-05.
- [ ] Decide whether the profile dropdown still earns a dropdown. With the dead Profile link gone it holds one item, Sign out, behind a click and its "Signed in as X" head only repeats the name and email the trigger already shows. Either put Sign out in the footer directly, or give the menu a second reason to exist. Overlaps the outside-click item above. Raised by review 2026-08-05.
- [ ] When copy+paste the verification code from the email, it doesn't work because it does not trim whitechars around the code in the form. It should trim automatically.
- [ ] Guard against the double-clicked submit, without client-side JavaScript. The README's non-technical persona double-clicks a button that doesn't respond instantly, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only (no client JS — priority: zero-JS spine), and it must not break an action that is *legitimately* repeatable (an increase-by-one button is not a duplicate, it is two increments). Sketch to evaluate: a CSS-only affordance so the second click has nothing to hit (`:active`/`:focus` state, or the submit visually and semantically settling), paired with the host recognising a duplicate on the server — same session, same route, same payload, within a short window — and then logging it and dropping the second rather than replaying it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form is stronger than hashing the payload, and the CSRF plumbing already mints per-request tokens), how long the window is, where the record lives given the app is stateless (in-memory like the revoke denylist, or push it to the upstream the plugin already writes to), and how a plugin declares a route as repeatable — an opt-out on the route, or opt-in per form. Raised 2026-08-04 with the personas.
- [ ] Decide the caching contract for rendered pages. Responses now carry `Vary: Accept-Language` (they content-negotiate), but nothing sets `Cache-Control` so a shared cache in front of the app has no instruction, and a signed-in page is not marked `private`. Pre-existing, surfaced by the i18n review 2026-08-03: either set the headers deliberately (public pages cacheable, gated pages `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. Its comment says "the icons the UI actually references", but `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go the way `i-gear` just did, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression (see AGENTS.md → the `ICON_NAMES` deviation). Pre-existing, surfaced by the review 2026-08-05.
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host** (maintainer, 2026-08-05), but nothing here has been run on one. Two known suspects, both from the `--user "$(id -u):$(id -g)"` idiom the E2E runner and the lockfile edit share: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer, so "the file belongs to you afterwards" may hold for a different reason or not at all. The same question covers rootless Docker, where README already says to *drop* the flag. Raised by the stability review 2026-08-05.
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks its most logic-bearing file (`console-guard.ts`). Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither.
- [ ] Decide whether Playwright's `workers` should be pinned. Unset, it sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate. Fine on the current act_runner; revisit if CI ever runs constrained.
- [ ] Record the browser floor Plainpages requires, and whether the fallback is the contract or a courtesy. The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet, capped at Safari 16, therefore gets panels flowing inline rather than working menus. Either state a supported floor or accept the fallback for those devices; nobody has rendered that path on real hardware.
- [ ] Decide whether the profile dropdown still earns a dropdown. It holds one item, Sign out, behind a click, and its "Signed in as X" head repeats what the trigger already shows.
- [ ] Trim whitespace around the verification code in the form — a copy+pasted code from the email currently fails.
- [ ] Guard against the double-clicked submit, without client-side JavaScript. A non-technical user clicks a button twice when nothing happens fast enough, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only, and it must not break an action that is *legitimately* repeatable. Sketch: a CSS-only affordance so the second click has nothing to hit, paired with the host recognising a duplicate on the server — same session, route and payload within a short window — then logging and dropping it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form beats hashing the payload, and the CSRF plumbing already mints per-request tokens), the window length, where the record lives given the app is stateless, and how a plugin declares a route repeatable.
- [ ] Decide the caching contract for rendered pages. Responses carry `Vary: Accept-Language` but nothing sets `Cache-Control`, so a shared cache has no instruction and a signed-in page is not marked `private`. Either set the headers deliberately (public cacheable, gated `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule applied to namespaces. A design question, not a naming one.
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
### Architectural review findings (2026-07-02)
Prioritized. Overall verdict: architecture is sound (contract-first plugin API, functional core/imperative shell, strong test seams); these are refinements.
Prioritized. Overall verdict: architecture is sound; these are refinements.
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-free `visual` suite; heavy e2e suites required-but-separate) and make the pipeline the only path to a published image (build once at tag, promote).
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES` — a core registry change for a cosmetic fix, so it was left out of the permission-naming branch. Raised by the stability review 2026-08-05.
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen (users, groups, clients, shifts) hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field, incl. identical brand-assembly in `chrome.ts` and `shell-context.ts`. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth.
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES`.
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. Record the decision.
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population (first-party vs external) to justify the versioning machinery.
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear.
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population.
## Finnished work
- [x] `e2e-tests/artifacts/` is written `root:root` into the checkout and needs `sudo` to delete — the Playwright container runs as root. Unlike the node_modules mountpoint, a container `user:` would fix it, but compose has no `$UID` of its own (needs an `.env` or `id -u` via `ci.sh`) and CI's artifact upload reads that dir. Found 2026-08-05. (Fixed with the idiom README already uses for a lockfile edit — every documented invocation passes `--user "$(id -u):$(id -g)"`: the five compose headers, the five README blocks and `ci.sh`'s two runs — so the runner writes as whoever started it, on a dev box and on a CI runner alike, and compose never needs a `$UID` of its own. Two things had to come with it. `e2e-tests/artifacts/` is now *tracked* (`.gitkeep`), because an absent bind-mount source is created by the daemon as root and an unprivileged runner then cannot write into it at all — the same trap the node_modules mountpoint hit, one layer up. And the runner image sets `HOME=/tmp` + `npm_config_cache=/tmp/.npm`, since an arbitrary uid has no home in the Playwright image. Baking a `USER` into the image was tried first and dropped: `pwuser` is **1001** in the noble image (uid 1000 is `ubuntu`), so it `EACCES`'d on a 1000-owned checkout, and no fixed uid can match every host. One premise turned out stale — no workflow uploads artifacts, so nothing in CI reads that dir. Verified by running the visual suite as uid 1000: 36 tests green across Chromium, Firefox and WebKit, every file written `lilleman:lilleman` and deletable without `sudo`, which this box does not even have. `src/compose.test.ts` guards every documented command plus the tracked mount point; `src/ci-gate.test.ts` guards the gate's own two.)
- [x] node_modules gets installed straight to the root folder with root permissions, it should at the very least be same owner as the one running the docker process, or built inside the docker image. (It *was* built in the image; the checkout got an empty root-owned dir — the mountpoint for `compose.override.yml`'s `- /app/node_modules` volume. Re-owning it is impossible (the daemon creates mount destinations as root whatever `--user` says), so deps moved to `/node_modules` above `WORKDIR /app` and the volume is gone. See AGENTS.md.)
- [x] Document permissions format so it is folled going forward: <resource>:<action>, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → [Naming a permission](README.md#naming-a-permission) is the one home for the rule, and the host *enforces* it at discovery — `isValidPermissionName` in `src/plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every declared name — so a badly-named permission stops the boot like any other bad manifest, for every plugin rather than only ones the admin GUI touches. `admin` is gone, split per screen into `users:`, `groups:` and `oauth2-clients:` × `read`/`write`. The read/write split is real, not cosmetic: `users:read` opens the list and is refused on every POST, and the Admin nav header lost its own gate so each screen is filtered by its own `:read` — hold none of the three and `composeNav` drops the emptied header (which needs the header to carry no `href`, now asserted). Two things had to be fixed to get here. The permission path validator was the *group* regex with no colon, so `/admin/permissions/scheduling:read` already 404'd. And `ADMIN_PERMISSIONS` defaulting to empty exposed that `bootstrap` never bind-mounted `plugins/` at all — it discovered only the image's empty copy, so a dropped-in plugin's permissions were never seeded; the mount lives in `compose.override.yml` (dev-only, mirroring `web`'s `.:/app`) because the base file gives both services the same baked copy and a base-file mount would collide with the e2e stacks that bind plugins *inside* that path. Quick start now says `docker compose up -d`, which re-runs the one-shot. Verified end to end on a live stack.)
- [x] Permissions should be a list in code. Since no permissions exists in the database out of the box, but there are a fixed number of permissions in the plugins that the end consumer and user of plain pages can use, these permissions must surface to the UI somehow. The effects is that the permissions page should be deleted completely, and the users and groups pages should gain the functionality to add permissions to their things instead, provided the user have the right permissiosn to do so, of course. Run the product reviewer agent on this todo also. (The host collects every installed plugin's declarations into one catalog — `declaredPermissions()``ctx.declaredPermissions`, deduped and sorted, computed once at wiring — and that catalog *is* the fixed list. The Permissions screen is deleted outright: its module, tests, three views, two partials and 29 catalog keys per locale. Users and Groups each gained a checkbox list of the catalog, ticked where held; the whole set posts back, so what is submitted is the desired state and `grantDiff` turns it into grants + revokes. Two properties earn their tests: a crafted POST cannot grant a name no plugin declares, and a held-but-undeclared name — left over from an uninstalled plugin — is never silently revoked by an unrelated save, since the picker only speaks for what it showed. A user's own change revokes their live tokens; a group's reaches members at their next re-mint, the documented transitive lag. Keto stays optional on the Users screen: without it the page still lists and edits, minus the picker. Maintainer's call 2026-08-05 to keep the OAuth2-clients screen and gate it `oauth2-clients:read/write` — permissions and OAuth2 are orthogonal, scopes say what an *app* may see and permissions what a *user* may do, so the screen only ever needed *a* gate.)
- [x] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix. (Every spec takes its `test` from `e2e-tests/console-guard.ts`, which watches every page a test opens — `console.error`, `console.warning`, and uncaught page errors — and fails the test that provoked one, at whatever step. The bar is zero rather than a curated tolerance list: the app ships no client JavaScript, so a message means a broken sub-resource, a rejected attribute, or an engine refusing a feature. Two narrow escapes, both explicit: the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), and per-test `allowConsole(/…/)` — used once, by the 404 spec, whose own navigation Chromium and WebKit log. **All browsers** is now literal for the Ory-free suites: `visual.spec.ts` + `language.spec.ts` run in Chromium, Firefox *and* WebKit — the per-test `@engines` tag is gone, and screenshots are written per project so the three don't fight over one file — which is what makes an engine-specific message visible at all. The Ory-backed suites write users, groups and sessions to one shared backend, so they stay on Chromium; widening them needs a stack per engine. Nothing in the app had to be fixed: the sweep found only the two above. Verified by negative control — an injected `console.warn` failed the test in all three engines and an injected `console.error` failed on full-flow's shared serial page — which also caught the guard registering that page twice. `src/e2e-console-guard.test.ts` locks the wiring in the *unit* gate, since a spec importing `test` straight from Playwright would run unwatched and green.)
- [x] Don't run tests when only markdown files in the root have changed. (Already shipped for *any* `*.md`, anywhere in the tree — `ci.sh`'s `docs_only()` no-ops the gate when every path changed since `main` ends in `.md`, and the workflow still pushes the commit-hash image so a merged docs commit stays releasable. Kept wider than "in the root" deliberately: no test reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it would spend the full gate on one. What was actually broken was rename detection — `git mv src/app.ts notes.md` names only the destination under `git diff --name-only`, and collapses to a single `R src/app.ts -> notes.md` line under `git status --porcelain`, so **moving code onto a `.md` path skipped the gate over a source file that was gone**. Both channels now pass `--no-renames`; verified against a scratch repo across ten scenarios — docs-only, mixed, empty diff, dirty tree, untracked code, deleted doc, and the rename staged *and* committed — the last two failing before the fix and passing after. `src/ci-gate.test.ts` locks both flags; it stays a text guard because the test image is `node:alpine` with neither `git` nor `bash`.)
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)
- [x] Organize the files in src in to folders so it is easier to understand the structure of the code.
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned.
- [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout.
- [x] Enforce `<resource>:<action>` permission names at discovery; split `admin` per screen.
- [x] Make the declared-permission catalog the fixed list; delete the Permissions screen, move granting onto Users and Groups.
- [x] Fail a Playwright test on any browser console warning, error or uncaught exception, in every engine.
- [x] Skip the CI gate when only markdown changed.
- [x] Replace the `<details>` popup menus with the Popover API so an outside click dismisses them.
- [x] Organize the files in src into folders.
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
- [x] The plugins/scheduling is an example and shouldn't be committed to the plugins directory since that should be empty to be able to be mounted in via docker or other means for the users/develoeprs using this application/framework. Put it in the examples folder instead.
- [x] The config folder should be empty and the current settings in the menu.ts should be the fallback default. IF a menu.ts where to appear in that folder, it should override the default settings with whatever is in it. The idea is the folder should be empty by default and you mount it in your docker container with your config.
- [x] Make the internal admin pages for users groups etc into a plugin instead in the examples folder and remove them from the internal source. Add a part in the quick start about copying this plugin into the plugins folder to enable GUI user- and group admining.
- [x] CI/CD - Test on push to any branch except main. (`.gitea/workflows/ci.yml` runs `bash ci.sh`; the one-time act_runner setup it needs is documented in README → CI/CD.)
- [x] CI/CD - Require PR to main and don't allow merge if tests does not pass. Only allow linear history and history that leaves the last commit hash on main the exact same as on the branch we just merged in. (Gitea branch protection on main + fast-forward-only merge style, set via API; documented in README → CI/CD.)
- [x] CI/CD - Sync up to github after every successful merge to main, URL: git@github.com:larvit/plainpages.git - also note the true home top of the README. Force push to github, it should only ever be a mirror of the gitea.larvit.se repository. (`.gitea/workflows/mirror.yml` force-pushes main + tags over HTTPS with a dedicated account's PAT in the `MIRROR_GITHUB_TOKEN` secret; setup documented in README → CI/CD.)
- [x] CI/CD - Build docker images as part of the requirements to be able to merge to main. Push them with the git commit hash as docker tag. Push to container registry at Gitea. (`ci.yml` builds + pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` after a green gate — with ff-only merges that is the main commit's image; auth via the `DOCKER_REGISTRY_USER` variable + `DOCKER_REGISTRY_TOKEN` secret, retention via an org cleanup rule; documented in README → CI/CD.)
- [x] CI/CD - Re-tag docker images from git hash to semver when a semver git tag is pushed. (`release.yml` on a `vX.Y.Z` tag pulls the commit-hash image and re-tags it `X.Y.Z`/`X.Y`/`X`/`latest`, failing loud if the gated image is missing; tag pushes also trigger the GitHub mirror; documented in README → CI/CD.)
- [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.)
- [x] Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via `docker run … tar` + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.)
- [x] CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (`renovate.yml` runs the self-hosted `renovate/renovate` image nightly against `renovate.json` — this repo only, via the shared `renovate@larvit.se` bot + `RENOVATE_TOKEN` secret, mirroring the `pwrpln/core` pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow `run:` steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.)
- [x] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. (The renovate job forwards the `RENOVATE_GITHUB_TOKEN` secret — a scopeless read-only github.com PAT; Gitea rejects `GITHUB_`-prefixed secret names — into the container as `GITHUB_COM_TOKEN`; documented in README → CI/CD.)
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
- [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.)
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
- [x] Add i18n support. (Catalogs are TS modules per locale — `src/i18n/locales/<tag>.ts` for the host, `plugins/<id>/i18n/<tag>.ts` for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by `?locale=sv-SE``Accept-Language``en-US`, exact on a full tag but a lone language takes the first regional catalog; no cookie — when the URL asked, the host carries `?locale` onto the links it renders and `ctx.localeHref()` does it for a plugin's. `ctx.t(key, vars)` plus `t`/`locale`/`locales`/`localeHref`/`dir` merged into every view (any include depth); `{{var}}` interpolation, plurals via `Intl.PluralRules`, an unknown key renders as itself — which is what makes a nav label either a key or plain text. Every catalog is checked against its set's en-US at boot (keys, kind, plural categories) and a mismatch stops startup. Kratos' own flow text is mapped by its numeric id (only ids verified against the live stack; its generic trait-label id is deliberately unmapped, field labels key on the input name instead). Zero-JS language picker in the shell + the auth/consent pages, `<html lang dir>` from the locale. Core, both example plugins and their views translated; unit tests + `e2e-tests/language.spec.ts` in the visual gate; documented in README → Languages, decisions in AGENTS.md.)
- [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
- [x] On the first page there is a button saying "Log in" and in the bottom left corner another button says "Sign in". Use a uniform language. (English now says **sign in / sign out / create account** everywhere; Swedish was already uniform. Three outliers went: the landing's `landing.signIn` "Log in", the registration submit `kratos.1040001` "Sign up" — under a "Create account" heading, and sv-SE already said "Skapa konto" — and `oauth.logoutExpired`'s "This logout request", whose sign-in twin said "sign-in request". The first-run banner says "sign in at" too, and the admin example's email hint says "the sign-in identifier". The rule is recorded in AGENTS.md → Rules and held by the author: a unit test asserting the verb shipped first and was dropped on the maintainer's call, since a build that fails on a word removes the judgment a growing UI needs. The two e2e specs that clicked "Log in" now scope to `#main-content`, since the anonymous sidebar carries a "Sign in" link of its own.)
- [x] When logged in, there is a "profile" link in the little box when I've clicked my username in the bottom left corner. There is no profile, so the link is dead. Remove it. (The `<button type="button">` in the sidebar's profile menu had no handler and — zero-JS spine — could never get one; gone from `views/partials/shell.ejs` along with the `shell.profile` key in both locales. Sign out is now the menu's only item; the profile block itself (avatar, name, email) is the summary and stays. `src/ui/shell.test.ts` asserts the menu holds no dead `type="button"`, and `e2e-tests/full-flow.spec.ts` asserts Sign out is the only item once the dropdown is open.)
- [x] There is a "Settings" in the bottom left (a little cog) showing a "Preferences" in a little menu when clicked. That is not in any spec, it exists when not even logged in and erh. Just remove. (Dropped from the sidebar footer in `views/partials/shell.ejs`, which now carries the profile menu — or Sign in when anonymous — plus the language picker. The `shell.settings`/`shell.preferences` catalog keys went with it in both locales, as did the then-unreferenced `i-gear` icon: `ICON_NAMES` is by definition the icons the UI references, so `views/partials/icons.ejs` was regenerated from it. Kratos' own `/settings` account flow is a different thing and is untouched. Covered by `src/ui/shell.test.ts` signed-in and anonymous, plus the public-landing case in `e2e-tests/visual.spec.ts`.)
### Architectural review findings (2026-07-02)
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts` (~380 lines).** It mixes the request pipeline with inline implementations of ~10 built-in endpoints (Kratos flows, /oauth2/*, /auth/complete, /logout, /, /dashboard, 404/405). Extract each endpoint into a named handler (auth/OAuth2 group → `src/auth/` route module) with the same `(req, res, ctx)` shape plugin routes use; reduce `handleRequest` to pipeline → internal route table → `sendResult`.
- [x] Move the scheduling example out of `plugins/` into `examples/`.
- [x] Make `config/` an empty drop-in mount with the defaults as fallback.
- [x] Turn the built-in admin pages into a drop-in example plugin.
- [x] CI/CD — test on push to any branch except main.
- [x] CI/CD — require a PR to main, gated on a green build, fast-forward-only.
- [x] CI/CD — force-push mirror to GitHub after every merge to main.
- [x] CI/CD — build and push the app image, tagged with the commit hash, as part of the gate.
- [x] CI/CD — re-tag the hash image to semver on a `vX.Y.Z` tag.
- [x] CI/CD — sync released tags to Docker Hub.
- [x] Write README-dockerhub.md for the Docker Hub overview.
- [x] CI/CD — set up the Renovate bot.
- [x] CI/CD — give Renovate a read-only `GITHUB_COM_TOKEN` so github.com lookups aren't rate-limited.
- [x] CI/CD — auto-release on Renovate updates, versioned from the `Release-Bump:` trailer.
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen.
- [x] Document the auth security model in the README.
- [x] Add i18n support.
- [x] Settle the identity-vs-user vocabulary.
- [x] Use one uniform verb per action in the English UI (sign in / sign out / create account).
- [x] Remove the dead "Profile" link from the sidebar profile menu.
- [x] Remove the unspecified "Settings"/"Preferences" cog from the sidebar footer.
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts`** — extract the built-in endpoints into named handlers on an internal route table.