# Plainpages A self-hostable **foundation for server-rendered web applications** — **public pages, access-controlled pages, or any mix**, built from a **zero-JS design system** with a **config-driven menu** and **optional authentication & authorization** baked in (any page can be public or gated). You add everything domain-specific by **dropping in plugin folders** — the admin UI for a webshop, a public service portal, a school scheduler, a water-treatment dashboard — without rebuilding auth, the menu, and the design system every time. > **True home: ** — development, issues, and PRs > live there. [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is a > read-only mirror, force-synced on every merge to `main`. ## Quick start > **Requirements:** **Docker** and **Docker Compose** — and nothing else. **1. Clone and start the whole stack.** ```bash git clone ssh://git@gitea.larvit.se:21022/larvit/plainpages.git cd plainpages docker compose up -d # http://localhost:3000, live-reloads on source changes ``` **2. Sign in.** Open and sign in as the seeded admin — **`admin@plainpages.local` / `admin`**. **3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`: ```bash cp -r examples/plugins/admin plugins/admin docker compose up -d ``` The bootstrap grants the seeded admin every permission the installed plugins declare, so the **Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the one-shot `bootstrap` service, and only `up` re-runs it. See [`examples/plugins/admin/`](examples/plugins/admin/). **4. Add your first plugin.** The clone is bind-mounted into the container, so a new folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`: ```ts import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ apiVersion: "1.0.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, ], }); ``` ```bash docker compose restart web ``` Visit — the page is mounted at `/hello` (the folder name is the plugin id *and* the mount path) and "Hello" is in the menu. That's the whole loop: **drop a folder in `plugins/`, restart, it's live.** A plugin that declares `permissions` needs `docker compose up -d` instead, so the seed re-runs and grants them (as in step 3). From here, render real pages against the app shell and fetch upstream data — see [Building plugins](#building-plugins) and the runnable reference in [`examples/plugins/scheduling/`](examples/plugins/scheduling/). ## Contents - [Overview](#overview) - [Users, groups & permissions](#users-groups--permissions) - [naming a permission](#naming-a-permission) - [a worked example](#a-worked-example) - [granting a permission](#granting-a-permission) - [fine-grained, per-row access](#fine-grained-per-row-access) - [Building plugins](#building-plugins) - [anatomy](#anatomy-of-a-plugin) - [the manifest](#the-manifest) - [routes & handlers](#routes--handlers) - [landing pages](#the-landing-pages-home--dashboard) - [RequestContext](#requestcontext) - [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface) - [nav & permission gates](#nav--permission-gates) - [versioning](#contract-versioning) - [conflict rules](#conflict-rules) - [hooks](#hooks) - [where they live & mounting](#where-plugins-live-and-how-to-mount-them) - [dependencies](#plugin-dependencies) - [local dev & test](#local-dev--test-story) - [The menu system](#the-menu-system) - [Building blocks](#building-blocks) - [Interactivity: zero-JS spine](#interactivity-zero-js-spine) - [Languages (i18n)](#languages-i18n) - [Configuration](#configuration) - [canonical host](#canonical-host-one-public-url) - [what you must supply](#what-you-must-supply-the-only-manual-prep) - [SSO](#social-sign-in-sso) - [Auth, sessions & access](#auth-sessions--access) - [login & the session JWT](#login-and-the-session-jwt) - [instant revoke](#instant-revoke-the-optional-denylist) - [three tiers](#three-tiers-of-may-i) - [OAuth2 (Hydra)](#oauth2-provider-hydra) - [security model](#security-model) - [Email](#email) - [Architecture](#architecture) - [Stateless](#stateless) - [Testing](#testing) - [end-to-end](#end-to-end-playwright) - [the full gate](#the-full-gate-one-command) - [CI/CD](#cicd) - [one-time setup](#one-time-ci-setup) - [Production & deployment](#production--deployment) - [Upgrading](#upgrading) - [Observability](#observability) - [JWT signing key & rotation](#jwt-signing-key--rotation) - [Project layout](#project-layout) - [Extending the core](#extending-the-core) ## Overview Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu, sessions, and access control — and stays out of your domain logic. **Any page can be public or gated**, so the same foundation serves a public site, a locked-down internal tool, or a public front with an authenticated area behind it. - **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design system + app shell, the config-driven menu, sessions, and access control. No domain screens. - **Opt-in admin plugin:** the **users, groups, and OAuth2-clients** screens ship as [`examples/plugins/admin/`](examples/plugins/admin/) — an ordinary plugin, reaching Ory through the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface. - **You add:** everything else domain-specific, as **plugins** — built from the same building blocks the admin plugin uses. **Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only, environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the same I/O-free JWT check, and there is no app database. **Plugins are the extension model.** The plugin API is the product's main surface: powerful, predictable, and overloadable — a plugin can take over as much of a page as it wants. The host **fails loud at boot/discovery** (bad manifest, version mismatch, conflict) rather than sandboxing at runtime; crash-isolation is a deliberate non-goal. See [Building plugins](#building-plugins). **Zero JavaScript**, so pages stay fast on low-end hardware and flaky links. Where a modern CSS feature removes the need for JS (theme switching, popovers, disclosure) we use it — the trade we avoid is shipping a client-side runtime, not using the platform. Markup is semantic and accessible (see [AGENTS.md](AGENTS.md)). ## Users, groups & permissions Authorization here is two hops: a **user** — directly, or through a **group** — is granted a **permission**, and that permission's *name* is exactly the string a plugin gates on. Grants are Keto relation tuples: `Permission:#granted@user:`, or `@Group:#members`. | Entity | Lives in | Answers | Example | | --- | --- | --- | --- | | **User** | Kratos | who you are | `user:0198f2c1-…` | | **Group** | Keto | who — a reusable set | `Group:support` | | **Permission** | Keto | what you may do | `Permission:scheduling:read` | | **Resource** | Keto | which specific row | `Resource:shift-4471` | Keto's whole model is one primitive — `namespace:object#relation@subject` — so those four namespaces are *ours*, declared in `ory/keto/namespaces.keto.ts`; Keto resolves them, transitively through nested groups. The app stores none of it — it is [stateless](#stateless). > **There is no `Role`.** A route gates on a single operation, so it gates on a **permission**. For > a bundle ("IT Support staff"), make a group and grant it several — groups nest. > > **Ory calls a user an "identity"** (its own docs use the terms interchangeably). Plainpages says > **user** everywhere; you meet Ory's spelling only in the Kratos API and the `Identity` type in > `src/auth/kratos-admin.ts` that mirrors it. ### Naming a permission **Every permission name is `:`** — `scheduling:read`, `users:write`, `oauth2-clients:read`. Both halves are lowercase letters, digits, dashes and underscores; discovery refuses a plugin that breaks the rule, so it holds for every installed plugin. - **``** names the thing acted on, not the plugin that owns it — names are one **global namespace**, so an operator grants `scheduling:read` once and every plugin referencing it is gated consistently. Pick one no other plugin would claim: `oauth2-clients`, not `clients`. - **``** names the operation. `read`/`write` cover most screens; use a specific verb when the operation really is distinct (`invoices:approve`). A bare word like `admin` says *who someone is*, not *what they may do* — that is a role, and roles are **groups** here. Split by resource and action, then bundle with a group: ``` Group:it-support ──> Permission:users:read, Permission:users:write, Permission:groups:read, … ``` Declaring a permission stays optional, so two plugins may deliberately share a name. > **A `:write` is not a small grant.** The split contains the **read** half — `users:read` alone is a > safe helpdesk grant — but `groups:write` lets someone add themselves to a group holding every > permission, and `users:write` lets them mint a recovery code for any account. Treat both as full > administrative access. ### A worked example Alice works support and leads scheduling; Bob works support; Carol administers the system. ``` people groups permissions ────── ────── ─────────── alice ──┬─────────> Group:support ────┐ │ ├──> Group:staff ──> Permission:scheduling:read bob ────┘ │ │ alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write carol ────────────> Group:it-support ─┬──> Permission:users:read └──> Permission:users:write ``` At login the host asks Keto which permissions the user holds, walking those arrows transitively, and bakes the answer into the session JWT (see [Login and the session JWT](#login-and-the-session-jwt)) — alice gets `scheduling:read` + `scheduling:write`, bob `scheduling:read`, carol `users:read` + `users:write`. **Permissions do not nest and there is no superuser**: carol's Users grant buys nothing on Groups or `/scheduling`. Against the reference plugins' actual routes: | Request | Gate | alice | bob | carol | anonymous | | --- | --- | --- | --- | --- | --- | | `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ | | `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` | | `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `GET /admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` | | `POST /admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` | | `GET /admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` | Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a 403; a signed-in user who merely lacks the permission gets the 403 page, since there is nothing to sign in *as* that would help. The menu is filtered by the same permissions, so nobody is shown a door they cannot open. ### Granting a permission Write the tuple. The admin plugin's **Users** and **Groups** screens do exactly this — each offers the declared permissions as a checkbox list — or use Keto's write API directly: ```bash # everyone in sched-leads may write shifts curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{ "namespace": "Permission", "object": "scheduling:write", "relation": "granted", "subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" } }' ``` **A permission's name is authored in plugin code; only its *grants* live in Keto.** A plugin declares the permissions it gates on (`permissions:` in the manifest) and the host collects them into one catalog — `ctx.declaredPermissions` — which is the fixed list the admin screens offer. Nothing in a GUI invents a name, and a tuple naming something no installed plugin declares gates nothing. A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant revoke](#instant-revoke-the-optional-denylist) when you need it sooner. ### Fine-grained, per-row access The `Resource` namespace covers what a coarse permission cannot express: *this* row, shared with *this* person. A `Resource` carries Keto `permits` (`view`, `edit`, `delete`, nesting as `owner` ⊇ `editor` ⊇ `viewer`) and never appears in the JWT. **A per-row grant never widens a coarse gate** — the route's `permission` is checked before the handler runs. Gate the route on something they hold, then narrow inside the handler: ```ts { method: "POST", path: "/shifts/:id", permission: READ, handler: editShift } async function editShift(ctx) { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" }))) throw new GuardError(403, "not an editor of this shift"); … } ``` Reserve this tier for relationship rules (sharing, delegation, inheritance). Ownership and tenant rules belong in the upstream service that holds the row — see [Three tiers of "may I?"](#three-tiers-of-may-i). ## Building plugins A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no registration step, no central wiring. Each plugin carries its own nav, routes, views, and CSS. The contract is **TypeScript** (`src/plugin-host/plugin.ts`); the types there are the source of truth and the sections below explain them and the rules the host enforces. A runnable example lives in **[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a permission-gated list page over an upstream service, a CSRF-guarded form, and a mix of public + gated nav. `plugins/` ships empty, so copy it in to run it (`cp -r examples/plugins/scheduling plugins/scheduling`, then restart); the dev compose already points `SCHEDULING_UPSTREAM` at its mock backend (`examples/shifts-upstream/`). ### Anatomy of a plugin ``` plugins/things/ # the plugin folder — its name is the id AND the mount path (→ /things) plugin.ts # REQUIRED — the one fixed filename; default-exports the manifest (definePlugin(...)) views/ # fixed name, optional — EJS the host renders for a { view } result things.ejs # your view files; a handler picks one with { view: "things" } public/ # fixed name, optional — static assets, served at /public/things/ things.css # your asset files i18n/ # fixed name, optional — this plugin's own catalogs (see Languages) en-US.ts # the baseline; sv-SE.ts et al are written against its type handlers.ts # your code, any names/layout — host never looks here; plugin.ts imports it service.ts # e.g. route handlers, upstream calls, domain helpers — design as you wish package.json # optional — only if you depend on npm packages (see Plugin dependencies) node_modules/ # yours, installed from your own lockfile ``` **Only `plugin.ts` is required.** `views/`, `public/` and `i18n/` are fixed folder *names* the host resolves against, but the files inside are yours to name. Everything else — handlers, upstream clients, their layout — the host never sees; `plugin.ts` simply imports it. **Identity comes from the folder.** The folder name *is* the plugin `id` and the mount path is `/`; neither is in the manifest, so they can't drift or be claimed twice. The id must be URL/path-safe (`isValidPluginId`: lowercase `a–z`, digits, dashes) and also namespaces the plugin's `views/`, its `/public//` assets, and by convention its nav/permission names. `RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`), the `oauth2` provider routes, and `public` — since plugin routes resolve first and a folder claiming one would silently shadow a built-in. `admin` is **not** reserved: the admin screens are themselves a plugin. Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart". ### The manifest A plugin imports its host surface from one module — **`@plainpages/plugin-api`** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the body/CSRF/list-query helpers). The host publishes it as a package, so it resolves from any depth and from a plugin folder that has a `package.json` of its own ([Plugin dependencies](#plugin-dependencies)). That barrel **is** the contract boundary — never a relative `../../src/...` path; the host refactors everything behind it freely. ```ts import { definePlugin } from "@plainpages/plugin-api"; import { listThings, createThings } from "./handlers.ts"; export default definePlugin({ apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning) // Nav fragment, merged into the global menu and permission-filtered per user. // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }], // Permissions this plugin gates on. Optional — see Nav & permission gates. permissions: [ { description: "View things", name: "things:read" }, { description: "Create and edit things", name: "things:write" }, ], // Route handlers, mounted under the plugin's path (/things). `permission` gates first. routes: [ { method: "GET", path: "/", permission: "things:read", handler: listThings }, { method: "POST", path: "/", permission: "things:write", handler: createThings }, ], }); ``` `definePlugin()` only types the object (`PluginManifest`) and returns it unchanged — a manifest may equally be a plain typed object. All validation happens at discovery, and the host attaches the folder-derived `id` to produce the loaded `Plugin`. | Field | Required | Notes | | --- | --- | --- | | `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). | | `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. | | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `routes` | no | See [Routes & handlers](#routes--handlers). | | `hooks` | no | See [Hooks](#hooks). | A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional. ### Routes & handlers A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's mount path `/`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the `permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login` with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page. `public: true` means no gate at all (see [Public pages](#public-pages--menu-items)). `method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`. A handler returns a **`RouteResult`** (or a `Promise` of one); the host turns it into the HTTP response. Returning `void` is the escape hatch — the handler wrote to `ctx.res` itself. ```ts // Optional on every variant below: status (HTTP status code) and headers (extra response headers). type ResponseMeta = { status?: number; headers?: Record }; type RouteResult = // Render the plugin's own view (plugins//views/.ejs) with `data`. | ResponseMeta & { view: string; data?: Record } // Pre-rendered HTML, sent as-is. | ResponseMeta & { html: string } // JSON body | ResponseMeta & { json: unknown } // Redirect to a URL (takes only status, no headers). | { redirect: string; status?: number }; ``` ```ts // handlers.ts import { parseListQuery, type RequestContext } from "@plainpages/plugin-api"; export async function listThings(ctx: RequestContext) { const q = parseListQuery(ctx.url); const rows = await fetch(`${upstream}/things?${ctx.url.searchParams}`).then((r) => r.json()); return { view: "things", data: { rows, q } }; // renders plugins/things/views/things.ejs } ``` - **`view`** resolves against the plugin's own `views/` (`src/plugin-host/view-resolver.ts`) — nested names like `"things/edit"` work, out-of-bounds names are refused. The template may `include()` the core building-block partials and its own. To load the plugin's own CSS, pass its `/public//x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`. - **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`: `requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and `check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check` to render the 403 page. - The handler **fetches its own data** from upstream; plugins hold no state (see [Stateless](#stateless)). - Default status: `200` for `view`/`html`/`json`, `303` for `redirect`. #### Escaping & the trust boundary The host does not sandbox plugin output, so a handler **owns the safety of the data it renders**: - **Raw HTML is raw.** An `{ html }` result and the `*.html` partial fields (`cell.html`, `error.html`, a menu `trigger.html`) are emitted **unescaped** — that's their purpose. Escape untrusted content before putting it there. - **Text is auto-escaped; URLs are not scheme-checked.** A URL field — nav `href`, a table cell link, a menu item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute, so a `javascript:` or `data:` URL from upstream data becomes live XSS. Pass any URL you don't control through **`safeUrl()`** from `@plainpages/plugin-api`; it collapses anything but relative/`http(s):` to `"#"`: ```ts import { safeUrl } from "@plainpages/plugin-api"; return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } }; ``` ### The landing pages (`home` & `dashboard`) The host has two replaceable landing slots, and a plugin may own either or both: | Slot | Path | Gate | Default | | --- | --- | --- | --- | | `home` | `/` | **public** — anyone | An intro page with prominent sign-in / register links. | | `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. | ```ts import { definePlugin } from "@plainpages/plugin-api"; import { landing, board } from "./pages.ts"; export default definePlugin({ apiVersion: "1.0.0", home: landing, // owns "/" — the public front page dashboard: board, // owns "/dashboard" — the post-login app home }); ``` Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and returns a `RouteResult`, typically a `view` from the plugin's own `views/`, rendered against the native app shell via `ctx.chrome`. On `home` (public) `ctx.user` may be `null`; on `dashboard` the host enforces the session gate first, so it is non-null — branch on `ctx.permissions` *inside* rather than gating `dashboard` on a permission, since there is no second dashboard to fall back to. Both slots answer `GET` and `HEAD`. Only **one** plugin may own each slot — two claiming either is a boot-stopping [conflict](#conflict-rules). Neither needs a `routes` entry; the host mounts them above the `/` route namespace. ### RequestContext Every handler receives one argument, the `RequestContext` (`src/http/context.ts`), built once per request: ```ts interface RequestContext { chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell user: User | null; // { id, email, permissions } from the verified session JWT, or null log: Log; // request-scoped logger, in this request's trace params: Record; // path params from the route match, e.g. /things/:id → { id } t: Translate; // t(key, vars) in this request's language (see Languages); an unknown key renders as itself locale: string; // the locale being served, e.g. "sv-SE" locales: string[]; // every installed locale, sorted localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders query: URLSearchParams; // alias of url.searchParams req: IncomingMessage; res: ServerResponse; permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check declaredPermissions: readonly PermissionDecl[]; // every permission the installed plugins declare, deduped + sorted — what *exists*, vs `permissions` = what this user *holds* system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them url: URL; verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie } ``` **`ctx.chrome`** — `{ brand, csrfToken, nav, signInHref, theme, user }`. Hand each field to the matching `partials/shell` local and a `view` result renders the **native app shell**, exactly as `examples/plugins/scheduling/views/overview.ejs` does; a field you omit falls back to its shell default rather than erroring. `chrome.nav` is the whole global menu — every installed plugin's fragment, already composed, permission-filtered and current-marked for this request. `chrome.signInHref` carries the current page as `return_to`. The same shell renders **every** page, so the menu looks identical signed in or out; `menu: false` drops the sidebar for a focused layout. **`ctx.verifyCsrf(submitted)`** guards a state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the secret and sets the cookie. It is **opt-in per handler** — a route that never calls it has no CSRF guard at all. **`ctx.t`** translates in the request's language; the same block (`t`, `locale`, `locales`, `localeHref`, `dir`) is merged into every view's data — see [Languages](#languages-i18n). **`ctx.log`** is a request-scoped [`@larvit/log`](https://www.npmjs.com/package/@larvit/log) logger already in this request's trace: `ctx.log.info("…", { key: "value" })`, and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch` that adds a client span and propagates `traceparent` downstream. The barrel also exports a standalone **`tracedFetch`** (reads the ambient request log) to default an upstream client's `fetch` to, as the reference plugin's `createUpstream` does. Output, level and OTLP export are the host's config. **Stability guarantee.** These fields are present and non-breaking across a major `apiVersion`; new ones may be added within it. `req`/`res` are the raw Node escape hatch — prefer the typed fields. ### System capabilities (the `ctx.system` surface) Most plugins fetch their own data from an upstream service they configure. A **system plugin** — one that administers *Plainpages' own* identity stack — needs the host's Ory admin clients and the instant-revoke hook instead. The host exposes those on **`ctx.system`**, and re-exports the client types + their error classes from `@plainpages/plugin-api`: ```ts interface SystemCapabilities { // every field optional — present only when the host wired it hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients) keto?: KetoClient; // relationship read/write (groups, permissions) kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users) revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist) } ``` `ctx.system` is **`undefined` unless the host wired at least one** of these. A system plugin treats every field as optional and **degrades when absent** — the host never fails a request over it. The **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the reference consumer: Users uses `kratosAdmin`, Groups and the permission pickers `keto`, OAuth2 clients `hydra`, and a deactivate/delete or user permission-change calls `revoke` so the change lands before the JWT TTL; a missing capability renders a themed 503. This is a **privileged** surface — the keys to identity and authorization — meant for first-party system plugins you author or vendor. An ordinary domain plugin ignores it. ### Nav & permission gates A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which applies the central override and then **filters per user** by the permissions in the session JWT: a node shows iff it is `public`, declares no `permission`, or the user holds that name. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there. **Gating a section header.** A `permission` on the header takes the whole subtree with it. When the children need *different* permissions, leave the header ungated and gate each child — `composeNav` drops a header whose children all filtered out. That only works while the header carries **no `href`**: give it one and it survives as an ungated leaf, visible to everyone. #### Public pages & menu items A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu. That is the same as omitting `permission`, but stated outright so public is a deliberate choice rather than a forgotten gate. The two are **mutually exclusive** — declaring both is refused at boot. A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden, and `ctx.permissions` is empty (branch with `can(ctx, …)`). The reference plugin's `/scheduling` Overview is public while its shifts list stays behind `scheduling:read`. Declaring the permissions you gate on is **optional but recommended**: it documents them, feeds conflict detection, and lets the bootstrap seed them onto the demo admin, so a dropped-in plugin works without editing host config. ### Contract versioning Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built against — against the host's `HOST_API_VERSION`. The host bumps **major** on a breaking manifest/handler change and **minor** on an additive one. At discovery it parses both with `parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies provider/consumer semantics in `checkApiVersion`: | Plugin `apiVersion` vs host | Result | Host action | | --- | --- | --- | | same major, same minor (patch ignored) | `ok` | load | | same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist | | same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host | | different major | `refuse` | **abort boot** — incompatible contract | | missing / not a valid semver | `refuse` | **abort boot** — must be declared | The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies the caret-style compatibility. ### Conflict rules The host detects collisions across all discovered plugins with `findConflicts` and resolves them **loudly — never last-write-wins**. `error` aborts boot; `warn` logs and continues. | Kind | Level | Rule | | --- | --- | --- | | `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. | | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/` prefix is unique), so this catches a plugin duplicating one of its own. | | `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | | `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). | | `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [``](#naming-a-permission) if unintended. | Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, a permission name that isn't [`:`](#naming-a-permission), or a route/nav node setting both `public` and `permission`. ### Hooks Optional, for reacting to system actions. A plugin's `hooks` may implement: | Hook | When | May | | --- | --- | --- | | `onBoot()` | after discovery, before the server listens | warm caches, validate upstream config | | `onRequest(ctx)` | before route matching | inspect, or **short-circuit** by returning a `RouteResult` | | `onResponse(ctx, result)` | after the handler | observe/log; cannot change the response | Hooks run in **discovery order** (plugins sorted by id). `onRequest` fires on every request that reaches routing (static assets bypass it); the **first** hook to return a `RouteResult` short-circuits — later hooks and the route handler are skipped, and that result renders against its own plugin's views. `onResponse` runs after a matched route's handler; its return value is ignored. Hooks are not sandboxed — a throwing hook fails loud (boot for `onBoot`, the request for the others). Keep them cheap: `onRequest` is on the hot path, though the host skips the pipeline entirely when no plugin declares a hook. ### Where plugins live (and how to mount them) The host scans **`/app/plugins/`** inside the `web` container, so "installing a plugin" means getting its folder there. **1. In your clone (the default dev loop).** Create `plugins//`; `docker compose up` bind-mounts the whole tree (`compose.override.yml`: `.:/app`), so a restart picks it up. **2. A plugin kept in its own repo, or added to a prebuilt image.** Bind-mount the plugin folder onto `/app/plugins/` with a small compose override. Plugins are stateless, so mount it read-only: ```yaml # compose.plugins.yml — mount external plugin folders into the host services: web: volumes: - ../my-plugin:/app/plugins/my-plugin:ro # host path : /app/plugins/ ``` ```bash # Dev: list the files explicitly (a third file disables the implicit override merge) docker compose -f compose.yml -f compose.override.yml -f compose.plugins.yml up # Prod (image already built, no source mount): docker compose -f compose.yml -f compose.plugins.yml up -d ``` A named volume works the same way (target `/app/plugins/`). For a **baked** production image, keep the plugin in the build context and it is `COPY`'d in at build time. A plugin kept in its own repo mounts whole, `package.json` and all — see below. ### Plugin dependencies A plugin may depend on npm packages. It owns them completely: its `package.json`, its lockfile and its `node_modules` live in the plugin folder, and nothing about them reaches the host's — installing a plugin is still just getting its folder to `/app/plugins/`. Write the manifest yourself — `"type": "module"` is required, and the host refuses a plugin without it, because that file (not the host's) is what tells Node how to parse everything beside it: ```json { "name": "things", "version": "0.0.0", "type": "module" } ``` Add a `plugins/things/.npmrc` too. The root one does not reach a `--prefix`, so without it npm writes ranges rather than the exact pins this project keeps everywhere: ```ini save-exact=true ``` Then install into the folder: ```bash # The uid keeps the files it writes yours rather than root's. docker compose run --rm --no-deps --user "$(id -u):$(id -g)" web npm install --prefix plugins/things ms ``` A plugin in its own repo runs its own `npm ci` instead and mounts the result — `node_modules` included, since the plugin folder *is* the repo. A baked image needs no extra step: the plugin's `node_modules` is part of the build context and is `COPY`'d in with the rest of the folder. - **Never ship a copy of `@plainpages/plugin-api`.** The host publishes it into `/node_modules`, above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A copy inside your plugin's own `node_modules` would shadow it with a *second* instance of the host's contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot. - **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package each get their own copy at their own version, so neither can break the other by upgrading — and keeping yours current, and audited, is yours to own. Renovate here watches every manifest in this repo, the example plugins included — a plugin in its own repo needs its own. - **Depend on packages that ship JavaScript.** Node refuses to strip types under `node_modules`, so a dependency whose entry is `.ts` fails at import with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. `npm run typecheck` covers `plugins/`, so a dependency shipping no types of its own needs its `@types/…` in your plugin's `devDependencies`. Typechecking a plugin repo standalone still needs the barrel's types on disk: typecheck it mounted under the host tree, or vendor a type stub **outside `node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery refuses, and it would travel with the folder you mount. ### Local dev & test story A plugin is a normal folder of TypeScript, tested the same way the core is — everything in Docker. `examples/plugins/scheduling/` is the worked example: thin handlers bound to an injectable upstream client, unit-tested in `shifts.test.ts` with a mocked `fetch` and a hand-built `ctx`. 1. **Unit-test handlers as pure functions** with `node --test` — no host needed. ```bash docker compose run --rm web npm test ``` 2. **Run one plugin against the host.** Get the folder into `/app/plugins/` and `docker compose up`. For an isolated harness, `createApp({ plugins: [myPlugin] })` mounts a single manifest so a test can assert its routes, nav and gating without the rest of the stack. 3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in `e2e-tests/` for each plugin page/form, run against the live `web` service with the plugin mounted. ## The menu system The menu is **driven entirely by config** and assembled from two sources: 1. **Plugin fragments** — each plugin contributes its own `nav` (above). 2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at boot) — where the operator reorders, renames, groups, or hides items (by node `id`), and sets branding (app name, logo, default theme). The override always wins, applied before the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply. `config/` is an **empty drop-in mount point** (like `plugins/`): it ships empty, and you supply `config/menu.ts` by copying the template ([`examples/config/menu.ts`](examples/config/menu.ts)) in or bind-mounting your own dir onto `/app/config` (a commented example sits in `compose.override.yml`). The file imports its typed builder from **`#menu-config`** (the subpath import mapped to `src/ui/menu-config.ts`), so it resolves wherever it's mounted (keep the mounted `config/` a plain dir — no `package.json` of its own): ```ts import { defineMenu } from "#menu-config"; export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } }); ``` Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session JWT (no per-request authz call), so the menu only shows what that person can reach. An item may instead be **`public: true`** to show it to everyone — mutually exclusive with `permission`. Branding (name, logo, default theme) renders in the app shell. **One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders in the same app shell on **every** page — dashboard, plugin pages, and the login / registration / recovery / front pages — so it looks identical signed in or out and just shows fewer items to an anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a chrome-free layout opts out with the shell's `menu: false`. ## Building blocks Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers, fully styled and zero-JS: - **Partials:** app shell, nav tree, filter bar, data table (sort / select / row actions), pagination, form fields, badges, menus, auth cards. - **Helpers:** `composeNav` (menu from config), `parseListQuery` (`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call). ## Interactivity: zero-JS spine The core and all building blocks **work with zero JavaScript** — theme switching and filtering are pure CSS + GET forms, and menus are the platform's own [popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API): a `