# Decisions "Goal N" is README → Goals. "Code health" is the goal every project shares: the code is easy to understand and change safely. ## `src/` is grouped by concern, and the core ships no domain screens 2026-06-24, the maintainer. Code health and goal 3. Valid while each concern folder stays one a reader can name. The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only tests import), 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. Even the admin GUI is a drop-in plugin (`examples/plugins/admin/`). ## Plugins and config import the host only through the two barrels 2026-08-17, the maintainer. Goal 7. Valid while plugin authors typecheck against a host tree rather than a published package. `@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and `#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are the whole contract surface, so don't "fix" either back to a relative path. ## The Ory client types are contract-visible through the barrel 2026-07-02, the maintainer. Goal 7. Valid while plugins get Ory admin clients through `ctx.system`. `@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor. ## The barrel is a package, so a plugin folder may carry its own `package.json` 2026-08-17, the maintainer. Goal 7. Valid while plugins may depend on npm packages (README → Plugin dependencies). The Dockerfile links the barrel into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own `node_modules`: two instances of the barrel break `instanceof` across the boundary. `plugin-api.test.ts` guards this by asserting both paths reach one module. ## Plugin storage hands over credentials, not a client 2026-08-18, the maintainer. Goals 3 and 7. Valid while the host stays stateless (AGENTS.md → priority 2). README → Plugin storage describes the contract. The host takes `postgres` only to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing it. `storage.ts` beside it stays pure, so `web` never loads a driver. `src/postgres.test.ts` guards both halves, because one value imported from the wrong module breaks this invisibly. The driver is never re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so don't trade one away in isolation: - Passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than stored, which keeps the host stateless. Whoever holds that secret holds every plugin database, so it ranks with the DB password itself. - The provisioning DSN reaches `bootstrap` only (`src/compose.test.ts` guards the split). - Provisioning never drops anything, so uninstalling a plugin cannot destroy data; boot logs the orphans instead. Because the host's copy sits in the ambient `/node_modules`, a plugin can `import "postgres"` without declaring it. That is incidental, not a packaging promise: a plugin must still depend on its own driver. ## Plugin settings are declared, not discovered 2026-08-23, the maintainer. Goals 7 and 10. Valid while settings reach plugins through the environment (README → Plugin settings). `settings.ts` is pure and takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the design: - The prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`: a plugin id `db` with key `url` would otherwise name the host's own `PLUGIN_DB_URL`. - Keys are camelCase, so the `camelCase → SNAKE_CASE` mapping is total and no two keys collide. `findConflicts` (`plugin-host/conflicts.ts`) catches the residual cross-plugin collision. - `required` and `default` are mutually exclusive. That lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author casts. - A secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`, not even as a mask or a length. An author mistake is refused at discovery, a bad operator value refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns, as an orphan database does. ## The trust boundary is the `web` process, not the plugin 2026-08-19, the maintainer. Goal 7. Valid while plugins are operator-installed code, not third-party uploads. Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So cross-plugin DB isolation is containment, and README says so. This follows AGENTS.md → priority 7: crash-isolation is a non-goal. `server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery imports any plugin module. That ordering is the whole point: move the line earlier if anything, never later. ## `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL 2026-08-19, the maintainer. Code health. Valid while Ory's databases are created by Postgres' first init. Don't re-assert the `REVOKE CONNECT` from `bootstrap`. `REVOKE` only warns when the caller doesn't own the database, so under the least-privilege provisioning account the README recommends it would report success while changing nothing. It also hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no `kratos`. The script runs only on first init, so a revoke added to it later never reaches a volume that already exists: `docker compose down -v` is the dev remedy, and a deployed install needs a migration. ## `bootstrap.ts` stays under `src/auth/` 2026-08-19, the maintainer. Code health. Valid until a third seeding concern lands. It provisions plugin databases as well as seeding Ory, but it is the one-shot service's entrypoint, not an auth module. Moving it to `src/bootstrap.ts` would edit `compose.yml`, five e2e compose files and `src/compose.test.ts` for a rename. ## `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook 2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools. Removing any field from the context costs a major bump, so all six stay. Adding one costs only a minor, so the context otherwise gains a field only when a plugin needs it. Pools handed to a plugin are reaped on process exit. ## `config/` is a plain dir with no `package.json` of its own 2026-07-01, the maintainer. Goal 7. Valid while an operator's menu override needs no dependencies. With a `package.json` there, `#menu-config` resolves against it instead and boot fails loud. If the override ever needs dependencies, `config/` needs the package treatment `plugins/` has. ## A plugin `package.json` without `"type": "module"` is refused, not warned 2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild. Refusing is the safe direction: refuse→warn relaxes freely, while warn→refuse breaks installed plugins. Allowing it would cost only a warning and a re-parse per file, not a break: Node detects module syntax, so even a `.js` helper loads. The cost of refusing is that an operator on a read-only third-party mount cannot apply the remedy. ## `examples/` mirrors the drop-in mount dirs 2026-07-01, the maintainer. Goal 9. Valid while the examples are meant to be copied in unchanged. `examples/plugins//` copies to `plugins//`, and `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve the host through the barrels, so each typechecks in place and copies across unchanged. Never commit real plugins or config into the root mount dirs: they ship empty. ## `ctx.chrome` is lazily memoized, never built unconditionally 2026-06-23, the maintainer. Goal 8. Valid while the public landing (`/`) renders without I/O. Don't 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. ## A plugin-owned render always runs on that plugin's context 2026-08-03, the maintainer. Goal 7. Valid while each plugin has its own translator. 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 2026-06-23, the maintainer. Goal 3. Valid while Kratos owns the recovery and verification flows. `web` never touches SMTP. Mail is customized through Kratos' `courier.template_override_path`. ## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role` 2026-08-03, the maintainer. Code health. Valid while Keto holds no namespace of its own. Keto ships no namespaces: all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts"). A role is a bundle, which here is just a group with several grants, and groups nest. Ory's own "permission" (the `Resource` `permits`: view, edit, delete) is the separate per-row tier. ## A permission name is always `:`, enforced at discovery 2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors. Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are groups here. `shapeError` checks every route, nav and profile-menu `permission`, and every declared name, with `isValidPermissionName` (`plugin-host/plugin.ts`). A bad name refuses the boot like any other manifest rule. The check is not left to the admin GUI, which an operator removes by not copying it in. ## Permission names are authored in plugin code; only grants live in Keto 2026-08-05, the maintainer. Goal 2. Valid while the admin screens offer only declared names. 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 there is no Permissions admin screen: nothing in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox list there. A Keto tuple naming something no installed plugin declares gates nothing, is not offered, and is never revoked by an unrelated save: the picker only speaks for what it showed. ## A permission's `` is global, not plugin-scoped 2026-08-05, the maintainer. Goal 7. Valid while cross-plugin sharing of permissions is wanted. Hence `oauth2-clients`, not `clients`. Users are the host's, and plugins share them. The cost is that collision-freedom is a convention rather than structural; the alternative penalizes the sharing case. ## Declaring a permission stays optional 2026-08-05, the maintainer. Goal 7. Valid while plugins may share a permission. Mandatory declaration would let `findConflicts` see every overlap, but it would then warn on exactly the legitimate sharing case. Shape is enforced; declaration is not. ## `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning 2026-08-05, the maintainer. Goal 10. Valid while `bootstrap` is the only writer of grants. Fail-loud belongs at the manifest boundary, where a developer authored the mistake. `bootstrap` gates `web`, so refusing operator env would take 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. ## `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api` 2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route. 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 verb, and a route table must answer "what does this need?" on its own. ## A gate is one of `public`, `session` or `permission`, named exactly once 2026-09-02, the maintainer. Goal 7. Valid while routes and nav nodes are declared in plugin manifests. A route or nav node names exactly one gate. Discovery refuses none, two, and a flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page. `src/auth/gate.ts` is the one home of the rule that the plugin router, the host's own route table and the menu all read. The rule binds manifests only; the comment on `allows` says why a node without a gate passes it. `session` exists because a plugin whose data is the visitor's own (their upstream account, their own tokens) has no distinction a permission could name. The alternative, granting every newly registered user a permission, couples the identity lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on `ctx.user.id`, never the email: an address is user-changeable and can be reassigned to someone who would then inherit the previous holder's rows. ## The reference plugin's two shift pages duplicate a view model and markup 2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product. Each page then reads top to bottom on its own, and an example rarely changes, so the two copies cost little. ## A `:read`-only holder is never shown a write affordance 2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits `:read` from `:write`. The list and detail models carry `canWrite`, and the views drop create, save, delete, add and 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`. Three guards go with it: - You cannot revoke your own direct grants, since undoing that self-lockout needs a `curl` against Keto. - A permission held through a group renders ticked but disabled, because unticked would state the opposite of the truth. - Unticking a permission on a group you are in, removing yourself (or a nested group you are in) from its members, or deleting the group is refused when you would lose a permission you don't hold directly. Undoing that self-lockout would need a `curl` against Keto too. ## `users:write` and `groups:write` each amount to full administrative access 2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits its write permissions by resource. `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. ## The CSRF token is unbound to the session 2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter. Leaving the token unbound is the usual cost of a signed double-submit token (`src/auth/csrf.ts`; README → Security model). `SameSite=Lax` and HSTS cover that cost. ## Plainpages says "user" everywhere, where Ory says "identity" 2026-08-05, the maintainer. Code health. Valid while Kratos' wire shape names the DTO `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. ## The locale lives in the URL, never in a cookie 2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address. The host resolves the locale from `?locale=sv-SE`, then `Accept-Language`, then `en-US`. When the URL asked for a locale, the host carries it onto the links it renders. A cookie would make a 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, so a plugin doesn't have to 2026-08-03, the maintainer. Goal 1. Valid while the locale lives in the URL. 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 the locale 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 the locale 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 the locale is the Kratos sign-in POST, to an absolute off-site URL. ## `locale` and the i18n view locals are reserved by the host 2026-08-03, the maintainer. Goal 7. Valid while the locale lives in the URL. `locale` is in `parseListQuery`'s reserved set, so a localized list page doesn't hand a plugin a phantom `locale` filter. The view locals `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`, `dir` and `assetHref` are merged after a handler's `data`, so a collision loses the key instead of breaking the shell. ## Catalogs are checked at boot, not at render 2026-08-03, the maintainer. Goal 7. Valid while catalogs are TypeScript modules loaded at boot. Every locale is compared against its set's `en-US`: keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires. A mismatch stops startup. A plugin may ship fewer locales than the host, with its strings falling back to `en-US` per key, but never one the host lacks. ## `locales/` at the repo root is a drop-in mount 2026-08-03, the maintainer. Goal 7. Valid while adding a language must not require forking the image. Like `plugins/` and `config/`: `locales/.ts` for the core, `locales/plugins//.ts` for a plugin. A new tag adds a language; an existing one replaces that catalog wholesale. The shipped `en-US` 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. ## The language picker is on every page, POST-rendered ones included 2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if that softens, hiding it after a POST is simpler. A POST-rendered URL often answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin Referer, else `/`. The accepted cost: switching language there leaves that POST's own result behind. ## An unknown translation key renders as itself 2026-08-03, the maintainer. Goal 7. Valid while labels may be either a key or plain text. That single rule 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, and the view escapes it 2026-08-03, the maintainer. Code health. Valid while views escape with `<%= %>`. Messages go through `<%= %>` like any other value. One carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move 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 2026-08-04, the maintainer. Goal 6. Valid while no deployment needs an RTL language. `textDirection` sets `` because that is free and correct, but the stylesheet keeps physical `left` and `right` properties. A genuine RTL locale needs those moved to logical ones first. ## A dropdown is a `