Files
plainpages/AGENTS.md
T
2026-08-05 11:15:37 +02:00

29 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)

  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 — 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-in fetch — no SDK dependency. New capabilities ship as plugin folders under plugins/ that fetch their data from upstream services, not as core code. See README.md for the architecture.
  3. Strict TypeScripttsconfig.json is strict (incl. noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax). Keep it that way. Prefer exact types and limit nullable and multi option types when possible. KISS.
  4. 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 in src/config.ts. Compose files set the toggles per deployment.
  5. 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 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, 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 — see README.md.
  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, 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 + the plugin-api.ts author barrel + system.ts, the ctx.system capability surface), and 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 (foo.test.ts beside foo.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/), not src/ code.
  • 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.)
  • Email is delegated to Kratos (it renders + sends recovery/verification mail); web never touches SMTP. Customization is Kratos' built-in courier.template_override_path, not app code — keeping web stateless and dependency-light (see Email).
  • Plugins and config import the host only via package.json imports#plugin-apisrc/plugin-host/plugin-api.ts, #menu-configsrc/ui/menu-config.ts — never a relative ../../src/* path. These two barrels are the whole author/operator contract surface; the src/* 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-api re-exports the Ory client types for the ctx.system surface (KratosAdmin/KetoClient/HydraAdmin + their DTOs and error classes). Those shapes are therefore contract-visible — changing them is a plugin-API break needing a major apiVersion bump, not a free refactor. Keep the Ory clients stable, or bump the version.
  • A plugin/config folder must stay a plain folder — no package.json of its own. Node resolves #-specifiers against the nearest parent package.json; a package.json inside the folder becomes its own scope and #plugin-api/#menu-config stop 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 local imports map / vendored stub).
  • examples/ mirrors the drop-in mount dirsexamples/plugins/<id>/ copies to plugins/<id>/, examples/config/menu.ts to config/menu.ts. Both mirror folders are in tsconfig.include and 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: UserGroupPermission, and there is no Role. Keto ships no namespaces — all four in ory/keto/namespaces.keto.ts are ours. Permission follows 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" (the Resource permits: 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 Identity DTO in src/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-SEAccept-Languageen-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-FIsv-SE), except that a lone language from Accept-Language takes 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 categories Intl.PluralRules says 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 to en-US per 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, field and menu wrap every href they render in localeHref; the nav and the sign-in link are wrapped upstream in chrome.ts; and the two GET forms (filter bar, rows-per-page) carry it as a hidden locale input, 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.localeHref remains for hrefs a plugin's own markup emits (the admin example's delete links). A form's action counts 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 to Accept-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.
  • locale is a host-owned query param. It is in parseListQuery's reserved set (list-query.ts), 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 names, merged after a handler's data so 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.tsswitchBase): 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 an onRequest short-circuit dispatch a plugin's handler, so they build the 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. 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, like plugins/ and config/locales/<tag>.ts for the core and locales/plugins/<id>/<tag>.ts for 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 SHIPPED en-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. textDirection sets <html dir> from the locale's script 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. 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 rename be either a key or plain text without a second field or a migration. Don't "fix" it into a loud failure: a manifest with plain labels must keep working.
  • t() returns raw text; the view escapes it. Messages go through <%= %> like any other value, so nothing is double-escaped; a message carrying markup uses <%- %>, and then its {{vars}} are escaped at the call site (see views/partials/pagination.ejs). Don't move escaping into t() — 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/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.
  • 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 (README-dockerhub.md is pasted into Docker Hub by hand), 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. 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 (node:24.19.0-alpine3.24) ships neither git nor bash, so it cannot exercise the function; behaviour was verified against a scratch repo across ten scenarios. Revisit if a .md ever becomes load-bearing. Decided 2026-08-05.
  • Plainpages is pre-announcement: no tags, no releases. The repo carried tags up to v0.2.2 from the auto-release job; all of them — and the semver container tags — were deleted 2026-08-05, and the job 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; same reasoning that freezes HOST_API_VERSION at 1.0.0. Note the coupling: registry-cleanup keeps 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 on main's tip. mirror.yml pushes tags with --prune so the deletions actually reach the public GitHub mirror; that makes the runner's tag view load-bearing (hence fetch-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's overflow. Four rules hold it together, none of them cosmetic. The panel carries position-anchor: auto — a bare anchor() resolves to nothing in Chromium, Firefox and WebKit alike, which is why the popover test in visual.spec.ts runs in all three rather than resting on a one-time manual measurement. The panel stays the trigger's next sibling inside the .menu wrapper, 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. The menu partial requires a caller-named id and fails loud without one: it is the popovertarget idref, 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 neither aria-expanded nor aria-haspopup is written: a zero-JS invoker cannot keep the first truthful, and the second would promise role="menu" keyboard semantics these panels do not implement. <details> stays where it means disclosure rather than popup: the nav tree. shell.ejs hand-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's Item shapes cover — keep the two in step, or fold it in if todo.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-gear left 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; the every 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 (see todo.md).
  • Anything the browser logs fails the E2E test that provoked it. Every spec takes its test from e2e-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), and allowConsole(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.ts locks the wiring in the unit gate: 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 rather than setup so a beforeAll is 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.ts are 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 @engines tag is gone: the whole Ory-free suite is the engine matrix now (ORY_FREE in e2e-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:

  1. 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.

  2. 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 .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 (Prio 1) — even standalone scripts run in bare node:24 containers (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 both package.jsons, 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 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_VERSION is frozen at 1.0.0 until the first external install, even for additive contract changes (i18n added four RequestContext fields 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 includes views/partials/*.ejs, not just the manifest and the barrel: the view resolver makes every core partial an include() root for a plugin's views, so their option names and emitted markup are author-visible (under this freeze the popover change dropped the menu partial's open? and rewrote its markup). Know the hole that leaves — discovery fails loud on a bad apiVersion, but include("menu", { open: true }) silently ignores the option and a plugin styling .menu > summary silently loses it. Promotion must cover the partial vocabulary too. Added 2026-08-05.
  • A plugin's apiVersion is a hand-written literal semver — the host version the plugin was built against — bumped by hand on rebuild, never the host's HOST_API_VERSION constant. Importing the constant makes every plugin always equal the host, so checkApiVersion can 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/:name and 404s malformed %-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher that re-parses ctx.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.system capability resolution, target fetch) into a small withX wrapper — see examples/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 own i18n/. Operator/developer-facing text — boot errors, log messages, guard messages — stays English. A pure view-model builder takes an optional t defaulting to its own English, so a unit test reads in words; handlers pass ctx.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".