23 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. - 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. - 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. Two things not to "fix": the panel must carryposition-anchor: auto— a bareanchor()resolves to nothing in Chromium, Firefox and WebKit (measured in all three before choosing) — and noaria-expandedis written, because a zero-JS invoker cannot keep one truthful; the state is the browser's to expose.<details>stays where it means disclosure rather than popup: the nav tree. 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).
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.- 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".