36 KiB
AGENTS.md
Guidance for AI agents and contributors working in this repo. Read README.md for
commands and layout.
How to work with tasks
Use the file todo.md.
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in todo.md. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
Project priorities (do not erode)
- Simplicity — prefer the solution that is easiest to understand, smallest, and most readable.
- Few dependencies — runtime deps stay minimal (today
ejs,lucide-static,@larvit/log— the last itself zero-dependency, for structured/OTLP logging). Prefer the Node standard library; justify any new dependency; do not add frameworks. The app is stateless — no database. Auth/identity/OAuth are Ory sidecar services (Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with built-infetch— no SDK dependency. New capabilities ship as plugin folders underplugins/that fetch their data from upstream services, not as core code. SeeREADME.mdfor the architecture. - Strict TypeScript —
tsconfig.jsonis strict (incl.noUncheckedIndexedAccess,exactOptionalPropertyTypes,verbatimModuleSyntax). Keep it that way. Prefer exact types and limit nullable and multi option types when possible. KISS. - Environment-agnostic — the app never asks which environment it runs in; there is
no
NODE_ENV(or equivalent) branching. Every behaviour is an explicit config toggle (e.g.CACHE_TEMPLATES,REQUIRE_SECURE_SECRETS, a future "disable email"), read once insrc/config.ts. Compose files set the toggles per deployment. - Semantic, accessible DOM — markup is a first-class concern. 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 overdiv+ ARIA. New views and partials keep this bar. - Full, parallel E2E — every user-facing flow (each page, form, guard, plugin route)
has a Playwright E2E test, and a new surface ships with its E2E in the same change.
Tests stay independent and side-effect-free so the suite runs
fullyParallel— keep it that way as it grows (never serialise on shared state); parallelism is what keeps it fast. E2E runs in Docker against the live stack — seeREADME.md. - Powerful, fail-loud plugins — the plugin API is the product's main surface and the
only way to add domain features. It optimises for being powerful, predictable, and
overloadable (a plugin can take over as much of a page as it wants), and the host
fails loud at boot/discovery (bad manifest, version mismatch, or conflict stops
startup with a clear message) rather than sandboxing at runtime. Runtime crash-isolation
is a deliberate non-goal — diagnose at deploy time, not in production. Keep this
contract stable; see
README.md→ Building plugins.
Deliberate architectural deviations (don't re-flag)
Intentional, reasoned choices — an architecture review should honor them, not re-raise them. Revisit only if the stated reason stops holding.
src/is grouped by concern, not flat —http/(request pipeline),auth/(session-JWT hot path, guards, and the Ory REST clients),i18n/(locale resolution + the catalogs,locales/holding the data),plugin-host/(discovery/router/hooks/view-resolver + theplugin-api.tsauthor barrel +system.ts, thectx.systemcapability surface), andui/(design-system view-models + menu/chrome);server.ts/config.ts/logger.tsand the topology-guard*.test.tsstay at the root. Tests are co-located (foo.test.tsbesidefoo.ts). Add a new module to the folder that owns its concern rather than to the root; don't reintroduce a flat tree. The core ships no domain screens — even the admin GUI (users/groups/permissions) is a drop-in plugin (examples/plugins/admin/), notsrc/code.ctx.chromeis lazily memoized — do not make it unconditional or move it into the base request context. It protects the I/O-free hot path on the public, bot-hit landing (/). (Declined twice.)- Email is delegated to Kratos (it renders + sends recovery/verification mail);
webnever touches SMTP. Customization is Kratos' built-incourier.template_override_path, not app code — keepingwebstateless and dependency-light (see Email). - Plugins and config import the host only via package.json
imports—#plugin-api→src/plugin-host/plugin-api.ts,#menu-config→src/ui/menu-config.ts— never a relative../../src/*path. These two barrels are the whole author/operator contract surface; thesrc/*behind them may be refactored freely. Depth-independent and refactor-stable by design — don't "fix" a#-import back to a relative path. One caveat:#plugin-apire-exports the Ory client types for thectx.systemsurface (KratosAdmin/KetoClient/HydraAdmin+ their DTOs and error classes). Those shapes are therefore contract-visible — changing them is a plugin-API break needing a majorapiVersionbump, not a free refactor. Keep the Ory clients stable, or bump the version. - A plugin/config folder must stay a plain folder — no
package.jsonof its own. Node resolves#-specifiers against the nearest parentpackage.json; apackage.jsoninside the folder becomes its own scope and#plugin-api/#menu-configstop resolving. Accepted cost of the#-import contract (fits the stateless, no-per-plugin-deps ethos). A plugin kept in its own repo typechecks against the barrel only when mounted under the host tree (or by adding a localimportsmap / vendored stub). examples/mirrors the drop-in mount dirs —examples/plugins/<id>/copies toplugins/<id>/,examples/config/menu.tstoconfig/menu.ts. Both mirror folders are intsconfig.includeand resolve the host surface via#-imports, so each example typechecks in place and copies across unchanged. Never commit real plugins/config into the root mount dirs (plugins/,config/) — they ship empty (.gitkeep, git-ignored otherwise).- Authorization vocabulary:
User→Group→Permission, and there is noRole. Keto ships no namespaces — all four inory/keto/namespaces.keto.tsare ours.Permissionfollows RBAC, where a permission is one operation ("read shifts") and a role is a bundle of them; a route gates on one operation, so it gates on a permission, and a bundle is just a group with several grants (groups nest). Ory's own "permission" (theResourcepermits: view/edit/delete) is the separate per-row tier. - A stricter manifest rule breaks already-copied plugins, and while
HOST_API_VERSIONis frozen the failure names a symptom rather than the cause.plugins/is an operator-owned drop-in mount that ships empty, so no test ever sees a stale copy — an operator's is whatever version they took. On 2026-08-05 the<resource>:<action>rule stopped a pre-existingplugins/adminat boot with "route gates on admin", which reads as the operator's bug rather than an out-of-date copy. Accepted during development (maintainer, 2026-08-05):checkApiVersionis already the right mechanism — a breaking manifest change bumps the major and a stale plugin is refused by version, which says plainly what happened. That only starts working once the freeze lifts, so until then a stricter rule ships with a README → Upgrading entry and the discovery error carries the re-copy hint. Valid whileHOST_API_VERSIONstays frozen at 1.0.0 — when the first external plugin lifts it (see the Rules section), the version check takes over and this note can go. Fail-loud stays right either way: the alternative is a route gating on a name nobody can be granted, i.e. a permanent silent 403. - 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-alladminpermission was exactly that mistake, split intousers:/groups:/permissions:/oauth2-clients:×read/write2026-08-05. Enforced at discovery (isValidPermissionNameinplugin-host/plugin.ts, checked byshapeErrorover every route/navpermissionand every declared name), fail-loud like every other manifest rule — not only in the admin GUI, which an operator removes by not copying the example in. Decisions around it:- Names are authored in plugin code; only grants live in Keto. The host collects every
installed plugin's declarations into one catalog (
declaredPermissions→ctx.declaredPermissions), and that catalog is the fixed list the admin screens offer. So there is no Permissions admin screen: nothing in a GUI invents a name, and holding one is a property of a user or a group, edited as a checkbox list on those two screens. A tuple in Keto naming something no installed plugin declares gates nothing and is not offered — and a save never revokes it, since the picker only speaks for what it showed. Decided with the maintainer 2026-08-05, replacing the CRUD Permissions screen. <resource>is global, not plugin-scoped (henceoauth2-clients, notclients). Deliberate cross-plugin sharing is a goal, so the pre-2026-08-05<id>:<action>guidance was wrong: users are the host's, not the admin plugin's. Cost: collision-freedom became a convention rather than structural. Accepted — the alternative penalizes the sharing case.- Declaring a permission stays optional. Requiring every gated route to declare its permission
would make
findConflictssee all overlaps, but would then warn on exactly the legitimate sharing case above. Shape is enforced; declaration is not. - There is no name-minting path in the GUI at all, which is what makes the discovery check the whole story: the only way a name comes into being is a plugin declaring it, and discovery refuses a badly-shaped declaration at boot. An earlier revision of this branch enforced the rule in the Permissions screen's create form instead and needed a second guard for the assign form, which could also mint one — deleting the screen removed both.
ADMIN_PERMISSIONSdefaults to empty: every permission is owned by the plugin that gates on it, and a host-invented default would gate nothing. An unusable value there is dropped with a warning, never fatal — fail-loud belongs at the manifest boundary, where a developer authored the mistake;bootstrapgatesweb, so refusing operator env takes the whole stack down. This is not hypothetical:adminwas this setting's own default until 2026-08-05, so a boot-breaking value is the expected leftover on upgrade, and a revision of this branch shipped exactly that bug past a green CI.e2e-tests/compose.auth.ymlnow seedsADMIN_PERMISSIONS: admin,users:readso the container proves it survives one; verified by negative control (re-adding the throw fails that suite at stack-up). This makes the seed a function of whatbootstrapdiscovers, and a plugin dropped in after first boot therefore needsdocker compose up -d(which re-runs the one-shot), notrestart web. The base file givesbootstrapandwebthe same bakedplugins/; onlycompose.override.yml's dev-only.:/appmakeswebdiverge onto the host tree, so the matching./pluginsmount forbootstraplives there and only there — in the base file it would desynchronise prod and collide with the e2e stacks, which bind individual plugins inside/app/plugins(a nested mount into a read-only parent is EROFS and the container never starts). Valid while bootstrap is the only writer of grants.actionForMethodis plugin-local and must not migrate into#plugin-api. Inside the admin example it buys one thing: the route table and the in-handler guard derive from one function, so 29 routes × 2 gate sites cannot drift. As a general mechanism it would make authorization a function of the transport verb, and a route table must answer "what does this need?" on its own.
- Names are authored in plugin code; only grants live in Keto. The host collects every
installed plugin's declarations into one catalog (
- A
:read-only holder must never be shown a write affordance. The split created a real read-only operator (a helpdesk account withusers:read), and the host's 403 is the backstop, not the UX: the list/detail models carrycanWriteand the views drop create/save/delete/add/remove, while the permission picker still renders — disabled — because seeing who holds what is the point of:read. A write-intent GET — a create form or a delete-confirm page — is the exception toactionForMethod: it gates on:write(declared in the route table and passed to the handler's guard, so the two still agree), because a page whose only purpose is to start a write should refuse a reader rather than render a form whose submit 403s. Two grant-specific guards go with it, both restoring behaviour the deleted Permissions screen had: you cannot revoke your own direct grants on the Users screen (self-lockout would need acurlagainst Keto to undo, which the operator persona can't do — same shape as the self-deactivate/ self-delete guards), and a permission held through a group renders ticked-but-disabled rather than unticked, because showing it unticked stated the opposite of the truth and unticking it wrote nothing while looking like a successful revoke. Known gap, same scope the deleted screen had: the group paths are unguarded — unticking a permission on a group you belong to, removing yourself from it, or deleting it can all still strip your own effective access. The robust "last effective holder" check needs a reverse Keto query and is deferred. Raised by the architecture + product + stability reviews 2026-08-05. users:writeandgroups:writeare equivalent to full administrative access, and the split does not change that:groups:writeadds you to any group, including one holding every permission;users:writemints a recovery code for any account. The containment the split buys is real on the read half only (users:readis a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs. Raised by the architecture review 2026-08-05.- Plainpages says "user" everywhere; Ory's word for it is "identity". Kratos calls the record
an identity, but Ory's own docs state it uses that term interchangeably with "users" and
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers
already know (Nielsen's heuristic #2: match between the system and the real world). One note in
README → Auth records the mapping so nobody has to rediscover it. The single exception is the
IdentityDTO insrc/auth/kratos-admin.ts, which mirrors Kratos' wire shape and keeps Ory's name — don't rename that one. - The locale lives in the URL, never in a cookie.
?locale=sv-SE→Accept-Language→en-US, and when the URL asked for one the host carries it onto the links it renders (ctx.localeHref). A cookie would make a page's language invisible in its address and unshareable; the cost is that a plugin must wrap its own hrefs. Matching is exact on a full tag (sv-FI≠sv-SE), except that a lone language fromAccept-Languagetakes the first regional catalog for it. Decided 2026-08-03. - Catalogs are checked at boot, not at render. Every locale is compared against its set's
en-US— keys, string-vs-plural kind, and the plural categoriesIntl.PluralRulessays that locale needs — and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer locales than the host (its strings fall back toen-USper key), never one the host lacks. - 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,fieldandmenuwrap every href they render inlocaleHref; the nav and the sign-in link are wrapped upstream inchrome.ts; and the two GET forms (filter bar, rows-per-page) carry it as a hiddenlocaleinput, since a GET submit replaces the whole query string and no href wrapper can reach it. Putting the obligation on each call site was tried first and missed five of eight sites inside one commit — including the admin screens.ctx.localeHrefremains for hrefs a plugin's own markup emits (the admin example's delete links). A form'sactioncounts as a link — a POST replaces the URL as completely as a GET submit, so the sign-out, consent and auth-card forms carry it too; without that, picking a language and then saving anything drops back toAccept-Language. The one round-trip that cannot carry it is the Kratos sign-in POST, whose action is an absolute off-site URL. Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw, so: when a link renders from the core chrome, it is the chrome's job to carry the locale. localeis a host-owned query param. It is inparseListQuery's reserved set (list-query.ts), so a localized list page doesn't hand a plugin a phantomlocalefilter; the i18n view locals (t,locale,locales,localeHref,localeParam,localeSwitch,dir) are likewise reserved names, merged after a handler'sdataso a collision loses the key instead of breaking the shell.- The language picker is on every page, POST-rendered ones included. Maintainer's call
2026-08-04, overriding an earlier decision to hide it there. The problem it was hiding is real: a
POST-rendered URL frequently answers no GET (
POST /admin/users/:id/recovery), so a link back to it dead-ends on a 405. The host therefore resolves the picker's target (app.ts→switchBase): this path when it answers GET, else the same-origin Referer, else/. Accepted cost: switching language on such a page leaves that POST's own result behind (a re-rendered form's input, or a one-time recovery code). Valid while the picker is expected on literally every page — if that ever softens, hiding it after a POST is the simpler answer. - A plugin-owned render always runs on that plugin's context. The landing slots (
home,dashboard) and anonRequestshort-circuit dispatch a plugin's handler, so they build the context withcontextFor(pluginId)exactly as a plugin route does — otherwisectx.tis the core translator and the plugin's own keys render as bare keys on the pages it owns. Found by review 2026-08-03 after all three paths shipped with the host's context. locales/at the repo root is a drop-in mount, likeplugins/andconfig/—locales/<tag>.tsfor the core andlocales/plugins/<id>/<tag>.tsfor an installed plugin, each adding a language or replacing that tag's catalog wholesale. Adding a language must never require forking the image or a vendored plugin folder. The SHIPPEDen-US(core's, or the plugin's own) stays the parity baseline even when the mount replaces it, so a mounted catalog is checked rather than trusted (one compared only against itself would boot green with the whole UI rendering keys), and each half is reported under the folder it actually lives in.- RTL is out of scope until there is a real use case.
textDirectionsets<html dir>from the locale's script because that is free and correct, but the stylesheet keeps physicalleft/rightproperties — a genuine RTL locale needs those moved to logical ones first. Don't convert the CSS or file findings about it on spec. Maintainer's call 2026-08-04; valid while no deployment needs an RTL language. A catalog there for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held to the same parity check. Adding a language must not require forking the image. - An unknown translation key renders as itself. That single rule is what lets a nav label,
branding, or a menu
renamebe either a key or plain text without a second field or a migration. Don't "fix" it into a loud failure: a manifest with plain labels must keep working. t()returns raw text; the view escapes it. Messages go through<%= %>like any other value, so nothing is double-escaped; a message carrying markup uses<%- %>, and then its{{vars}}are escaped at the call site (seeviews/partials/pagination.ejs). Don't move escaping intot()— every other value in a view would then be the odd one out.- CI docker logins share the runner host's Docker config. The act_runner is host-mode, so
docker login/logoutin 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>-webtag. Accepted for a single-maintainer cadence; serialize with a workflowconcurrencygroup if it ever bites. - The docs-only CI skip is
*.mdanywhere in the tree, not just the root. No test, build step or workflow reads a markdown file (README-dockerhub.mdis pasted into Docker Hub by hand), so a nestedexamples/plugins/admin/README.mdedit is as safe to skip asREADME.md, and narrowing it would spend the full gate on one. Both git channels inci.sh'sdocs_only()pass--no-renames: rename detection names only the destination, sogit mv src/app.ts notes.mdotherwise read as docs and skipped the gate over a source file that was gone.src/ci-gate.test.tslocks the flags as a text guard — the test image (node:24.19.0-alpine3.24) ships neithergitnorbash, so it cannot exercise the function; behaviour was verified against a scratch repo across ten scenarios. Revisit if a.mdever becomes load-bearing. Decided 2026-08-05. - Plainpages is pre-announcement: no tags, no releases. The repo carried tags up to
v0.2.2from theauto-releasejob; all of them — and the semver container tags — were deleted 2026-08-05, and the job is gated behind theAUTO_RELEASEActions variable (unset ⇒ skipped, the fail-safe direction on every unknown-varspath). A version only communicates to consumers, and there are none; same reasoning that freezesHOST_API_VERSIONat 1.0.0. Note the coupling:registry-cleanupkeeps a hash image only while its commit is a branch head or release-tagged, so with zero tags only branch heads survive the nightly prune — a hand-cut tag must sit onmain's tip.mirror.ymlpushes tags with--pruneso the deletions actually reach the public GitHub mirror; that makes the runner's tag view load-bearing (hencefetch-tags: true) and means a tag or Release created on GitHub is swept away, so releases are cut on Gitea only. Valid until the maintainer says Plainpages is ready to show people. - A dropdown is a
<button popovertarget>+[popover], never a<details>. The browser then owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole point), and the panel sits in the top layer so a row kebab is no longer clipped by.table-wrap'soverflow. Four rules hold it together, none of them cosmetic. The panel carriesposition-anchor: auto— a bareanchor()resolves to nothing in Chromium, Firefox and WebKit alike, which is why the popover test invisual.spec.tsruns in all three rather than resting on a one-time manual measurement. The panel stays the trigger's next sibling inside the.menuwrapper, because the open-state style and the old-browser fallback both read that adjacency, and a two-element partial cannot be dropped into an arbitrary layout. Themenupartial requires a caller-namedidand fails loud without one: it is thepopovertargetidref, and generated random ids were tried and rejected the same day — nondeterministic HTML forecloses the still-open caching decision and names nothing a reader can use. And neitheraria-expandednoraria-haspopupis written: a zero-JS invoker cannot keep the first truthful, and the second would promiserole="menu"keyboard semantics these panels do not implement.<details>stays where it means disclosure rather than popup: the nav tree.shell.ejshand-rolls the same block for the profile menu because its trigger composes escaped user values and its one item is a CSRF POST form, neither of which the partial'sItemshapes cover — keep the two in step, or fold it in iftodo.md's "does the profile dropdown still earn a dropdown" settles the other way. Decided 2026-08-05. ICON_NAMES(src/ui/icons.ts) is a host-owned registry, not a frozen plugin contract. It is deliberately not re-exported from#plugin-api, and README → Nav & permission gates already tells an author that using a new icon means registering it there. So the palette may narrow when the last reference to an id goes —i-gearleft with the settings menu 2026-08-05 — and a plugin needing one gets it re-registered in the same change. Accepted cost: an unknown sprite id renders a blank icon instead of failing loud; theevery icon <use> resolves to a defined <symbol>e2e test catches it for anything reaching the nav. Removing an id is a core edit, so weigh it per icon rather than sweeping the registry — a few ids are registered ahead of a caller (seetodo.md).- Anything the browser logs fails the E2E test that provoked it. Every spec takes its
testfrome2e-tests/console-guard.ts, which watches every page a test opens: a console error or warning, or an uncaught exception, fails that test. A zero-JS app has nothing to say in the console, so the bar is zero rather than a curated list of tolerated noise — and the two exceptions are explicit and narrow: one module-level allowance for the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), andallowConsole(re)for a test whose own page provokes a message on purpose — the 404 spec, whose navigation Chromium and WebKit log. Each record carries the message's origin URL, so that allowance can name the page under test and still see a sub-resource of it 404.src/e2e-console-guard.test.tslocks the wiring in the unit gate: a spec importingteststraight from Playwright — or minting a page with a rawnewPage()instead ofwatchedPage()— would run unwatched and green. The buffer clears at teardown rather than setup so abeforeAllis watched too (full-flow runs a whole login in one); the accepted cost is that a page outliving its test, as a serial describe's does, can log late and fail the next test instead of its own. Verified by negative control in all three engines. - The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.
visual.spec.ts+language.spec.tsare side-effect-free, so three parallel runs don't collide, and a console message only appears in the engine that renders the page — the reason the per-test@enginestag is gone: the whole Ory-free suite is the engine matrix now (ORY_FREEine2e-tests/playwright.config.ts). The rest write users, groups and sessions to one shared backend, where a second engine's run would race the first, so widening them means giving each engine its own stack. Screenshots are written per project name for the same reason. Decided 2026-08-05.
Docker only — no host tooling
Everything (install, typecheck, test, run, build, deploy) goes through Docker /
Docker Compose. Never run node, npm, or tsc on the host.
docker compose up # dev server, live reload
docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory)
docker compose run --rm --no-deps web npm test # tests
docker compose -f compose.yml up --build -d # production
README structure (keep it this way)
README.md serves two readers, in this order — preserve it when editing:
-
First-time reader (top). A one/two-sentence tagline, then a Quick start that gets the stack up (
docker compose up, sign in) and a minimal plugin live. Nothing comes before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the example plugin as small as possible; deeper detail lives in its own section, linked. -
Returning developer (rest). A Contents ToC immediately after Quick start, then sections ordered by what a developer adopting Plainpages reaches for, in priority order — not by architectural layering. The value that sets the order: getting up and running building plugins comes first, then configuring and securing the system (Configuration, Auth); the inner workings (Architecture) and ops/runbooks are deliberately deferred — they're not top of mind when starting out. Concretely: Overview → Users, groups & permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email → Architecture → Testing → Production → Observability → the JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place it by this value (how early an adopter needs it), not by where it sits in the stack.
Users, groups & permissions precedes Building plugins because a manifest's
permission:gate is unreadable without the model, and operators need it as much as plugin authors. It is the one home for that model — the plugin and auth sections link to it rather than restating it.
When editing: put content in the section it belongs to (don't prepend rationale above Quick
start); keep the ToC in sync when you add/rename/remove an H2/H3; and state each fact in
one home, linking to it rather than restating (credentials, env vars, rotation steps).
Don't document internals here. How a script reaches a decision, why one run behaved differently from another, what a function guards — a developer doesn't need it day to day and can read it off the code or a run's log in seconds. Prose like that only makes the README longer and harder to consume, for humans and machines alike. It belongs in the code it describes, or nowhere. The README earns its length on what you cannot dig out: how to use and operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens). Same test before adding a row to a table or the file map — a clause, not a paragraph.
Rules
- Node 24 runs
.tsdirectly (type stripping). Keep all TypeScript erasable (erasableSyntaxOnlyis on): noenum,namespace, parameter properties, or decorators. Import local modules with their.tsextension. - No
.mjs. Write modules as.ts(Prio 1) — even standalone scripts run in barenode:24containers (the e2e mock servers,examples/shifts-upstream/server.ts): Node strips types and detects ESM from syntax, no package.json needed. If a file genuinely must be plain JavaScript, use.js(Prio 2);"type": "module"is already set in bothpackage.jsons, so.jsis ESM. - No build step and no compiled artifacts — do not add a bundler or
tscemit. - Before finishing a change, run the typecheck and tests above; both must pass.
- Tests use the built-in
node --testrunner — no test framework dependency. - English everywhere. Keep code comments short and information-dense. Self explained code without any comment at all is the preferred solution.
- Do not comment about history in the code or README. Like "This function included X before, but it moved to Y".
- Do not comment about the absence of things, if it is not very unexpected. Banned is things like "This function does not calculate pi, that is done in function Z".
- Pin all dependencies and Docker images to exact, human-readable semantic
versions — never ranges (
^,~) and never digests/hashes. npm deps are kept exact by.npmrc(save-exact=true) +npm ci; the base image by tag (e.g.node:24.16.0-alpine3.24). HOST_API_VERSIONis frozen at 1.0.0 until the first external install, even for additive contract changes (i18n added fourRequestContextfields and several barrel exports without a minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild, a version bump can only produce noise. The promotion trigger is the first external plugin — from then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03. The frozen surface includesviews/partials/*.ejs, not just the manifest and the barrel: the view resolver makes every core partial aninclude()root for a plugin's views, so their option names and emitted markup are author-visible (under this freeze the popover change dropped themenupartial'sopen?and rewrote its markup). Know the hole that leaves — discovery fails loud on a badapiVersion, butinclude("menu", { open: true })silently ignores the option and a plugin styling.menu > summarysilently loses it. Promotion must cover the partial vocabulary too. Added 2026-08-05.- A plugin's
apiVersionis a hand-written literal semver — the host version the plugin was built against — bumped by hand on rebuild, never the host'sHOST_API_VERSIONconstant. Importing the constant makes every plugin always equal the host, socheckApiVersioncan never fire and a breaking change slips through silently. - Plugin route handlers are thin and per-route, keyed on
ctx.params. Register one handler per{method, path}in the manifest (the host extracts:id/:nameand 404s malformed%-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher that re-parsesctx.url.pathname: it duplicates the URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup (auth gate,ctx.systemcapability resolution, target fetch) into a smallwithXwrapper — seeexamples/plugins/admin/. handleRequest(src/http/app.ts) is a known complexity hotspot — ~160 lines tracking canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what remains is orchestration. Planned split along those seams; don't grow it further without taking one out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change.- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
- A user-visible string belongs in a catalog, not in the code or a view. Core strings go in
src/i18n/locales/en-US.ts(then every other locale, or the boot fails); a plugin's go in its owni18n/. Operator/developer-facing text — boot errors, log messages, guard messages — stays English. A pure view-model builder takes an optionaltdefaulting to its own English, so a unit test reads in words; handlers passctx.t. - One verb per action in the English UI: sign in, sign out, create account. Not "log in", "log out" or "sign up", inflections included — a second spelling for one button reads as a second thing; the noun ("a sign-in error", "the sign-in identifier") is unaffected. A plugin's catalog and every other locale follow the same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when it matters. Held by the author, never by a test: as the UI grows, slightly different wording is often the right call, and a check that fails the build on a word takes that judgment away. Maintainer's call 2026-08-05, dropping the guard that shipped with the rule.
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y" and not "ids=x,y".