# 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 / Permissions / 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 to pick up the new plugin's permissions (it is idempotent, so re-running costs nothing). 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 "#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) - [how it compares](#how-it-compares) - [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) - [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) - [Production & deployment](#production--deployment) - [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 purely public site, a fully locked-down internal tool, or the common middle: a public front with an authenticated area behind it. Its **sweet spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth time, but nothing ties it to internal-only use. The core itself ships **no domain screens at all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin. **Who it's for.** Experienced developers building server-rendered web products — back-office and operational tools, dashboards, portals, or public sites with a gated area — for their own use or for a client. You know HTTP, Docker, and identity providers, and you'd rather assemble pages from building blocks than fight a framework or hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its moving parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--access)) reads as obvious rather than surprising, you're the audience. **Who *they* build for.** The people who end up in front of a Plainpages app are not the audience above, and three of them shape the design more than any feature request does: - **The end user** — anyone using the product you assemble from Plainpages + your plugins. They never hear the word "plugin": to them the menu, the screens and the sign-in are one app, which is why the shell, the auth pages and every plugin share one design system, one menu and one language. - **The power user** — lives in the app all day. Ctrl-clicks a row to open it in a new tab, bookmarks a filtered-and-sorted list to come back to on Monday, sends that URL to a colleague, and edits the query string by hand when it's faster. This is why list state and the chosen language live **in the URL** and why every navigation is a real ``: middle-click, "open in new tab", back, and bookmark all have to work without a second thought. - **The non-technical user** — clicks a button twice when nothing happens fast enough, never touches the tab key, doesn't distinguish a link from a button, and won't recognise an error code. This is why destructive actions go through a confirm page instead of an inline `?confirm=1`, why a form's labels are clickable and its errors sit next to the field they belong to, and why a page must never depend on keyboard-only affordances. A double-clicked submit is a real event, not a misuse. **Included vs. what you add.** - **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, permissions, and OAuth2-clients** screens (users via Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as [`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for user & group admin. It's an ordinary plugin, using the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface to reach Ory. - **You add:** everything else domain-specific, as **plugins** — a list page, a form, a scheduler, a register, a dashboard — built from the same building blocks the admin plugin uses. **Priorities (unchanged from day one):** **simplicity, few dependencies, strict TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every behaviour is an explicit config toggle). Heavy lifting that *isn't* simple to do well — identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory** sidecar services rather than reinvented. "Simple" is about the *whole architecture* staying simple — not just at the start, but after you've dropped in 240 plugins and run it hard in production. 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's no app database to scale or migrate. **Plugins are the extension model — powerful, predictable, fail-loud.** Everything domain-specific is a plugin, and the plugin API is the product's main surface, written for experienced developers. It optimises for being **powerful, predictable, and overloadable** — a plugin can take over as much of a page as it wants. The host **fails loud at boot/discovery** rather than sandboxing at runtime: a malformed manifest, a version mismatch, or a conflict stops startup with a clear message. Runtime crash-isolation (one bad plugin can't take the host down) is a deliberate **non-goal** — diagnose at deploy time, not in production. See [Building plugins](#building-plugins). **Low-end by design.** Plainpages deliberately targets **low-end systems, odd hardware, and low-bandwidth environments** — a tablet on a factory floor, an old thin client at a reception desk, a remote site on a flaky link. That's *why* the baseline is boring, standards-compliant **HTML + CSS with zero JavaScript**: it loads fast, degrades gracefully, and works on whatever browser is already there. Where a modern **CSS** feature removes the need for JavaScript (theme switching, popovers, disclosure) we use it — the trade we avoid is shipping a client-side runtime, not using the platform. That standards-first stance also makes **semantic, accessible markup** a priority: real landmarks, one `

` per page, lists and tables with proper headers, a skip link, and ARIA (`aria-current`/`aria-sort`) only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)). ### How it compares The space around Plainpages is crowded, but it splits into families that each share **one** of its traits and miss the rest. Here's the map — established names per family, and where Plainpages sits relative to them: | Family · examples | What it is | Where Plainpages differs | | --- | --- | --- | | **Modular app frameworks** — Odoo · Frappe · OrchardCore · ABP | extend by dropping a **module folder** in; server-rendered | Closest in *shape* to the plugin model, but each is **metadata/model-driven with its own ORM/DB** and a large framework. Plainpages keeps the folder model while staying **stateless, framework-light, and component-not-generator**. | | **Developer portals / IDPs** — Backstage · Port · Cortex · Roadie · OpsLevel · Compass | plugin-based internal platforms with a service catalog | Closest on the **plugin** axis, but heavy **React SPAs** with a build step, built to catalog services. Plainpages is **zero-JS, few-deps, no-build** and renders general pages, not a catalog. | | **Model-driven auto-admin** — Django Admin · AdminJS · Filament · ActiveAdmin · EasyAdmin · Sonata · sqladmin · Starlette-admin | generate a CRUD UI **from your ORM/DB models** | Plainpages is a **component library, not a generator** — there is **no app DB** to model against; handlers fetch from upstream and you assemble the page. | | **Schema-driven content platforms** — KeystoneJS · Payload · Directus · Strapi · Wagtail | define a content schema, get an admin **+ API**; they own the data | Plainpages owns **no data** and isn't schema-first; it renders pages over services you already run, rather than being the system of record. | | **Naked-objects / runtime UI** — Apache Causeway · OpenXava · JHipster | the UI is **auto-projected from domain objects** (the generator extreme) | The opposite stance: Plainpages hands you **building blocks to assemble**, with no domain model driving the screen. | | **Low-code builders** — Retool · Appsmith · ToolJet · Budibase · NocoBase · ILLA | drag-and-drop GUI builders, **client-JS-heavy**, runtime state | Plainpages is **code-first and zero-JS** — server-rendered HTML versioned in your repo, no visual editor or runtime app state. | | **Code-first internal-tool platforms** — Windmill · Lowdefy · Superblocks | turn **scripts/config into auto-generated UIs** | Closest in *spirit* (for developers, self-hosted), but script/workflow-runner-centric. Plainpages gives you **full pages you control**, not a UI inferred from a function signature. | | **Hypermedia / zero-JS movement** — htmx · Hotwire/Turbo · Unpoly · Datastar | the **server-rendered-HTML philosophy** Plainpages is built on | These are *techniques*, not a foundation — no auth, menu, or plugins. Plainpages is what you **assemble with** the approach (and plugins may opt into htmx). *(Phoenix LiveView shares it but trades in a stateful socket.)* | | **CSS-only admin shells** — AdminLTE · Tabler · Bootstrap themes | a **visual shell** — markup + styles only | No backend, auth, routing, or extension model. Plainpages **includes the shell** and adds the hard-every-time parts. | | **Themed auth UI on Ory** — Kratos self-service UIs (`ory/kratos-selfservice-ui-node`, `kratos-admin-ui`) | the **login / registration screens** over Ory | The one *slice* with a direct off-the-shelf alternative: Plainpages reimplements it inside its own shell, so you could swap it out to avoid maintaining that part. | No family combines the whole set: **[drop-in plugin folders](#building-plugins)**, a **zero-JS server-rendered** design system, **[optional auth](#auth-sessions--access)** (any page public or gated), **no app database**, and a **framework-light TypeScript** core with no build step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection. ## 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. - **Group** answers *who* — a reusable set of people. Optional: a permission can be granted straight to a user. - **Permission** answers *what* — its **name is the string** you write in a manifest's `permission:` gate. - **A relation tuple** is the grant: `Permission:#granted@user:`, or `@Group:#members`. - **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it. | 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` | Users live in Kratos; every authorization edge is a Keto relation tuple. The app itself stores none of it — it is [stateless](#stateless). **Keto ships no entities of its own.** Its entire model is one primitive — `namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in `ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including transitively, through nested groups). > **Ory calls a user an "identity".** Kratos owns that record and names it so: its API is > `/admin/identities`, and a session carries `session.identity`. Plainpages says **user** > everywhere, because that is the word readers already know — and Ory's own documentation states > it uses "identity" interchangeably with "users" and "accounts". You will meet Ory's spelling in > exactly two places: the Kratos API itself, and the `Identity` type in `src/auth/kratos-admin.ts` > that mirrors it. > **There is no `Role`.** In RBAC a permission is a single operation ("read shifts") and a role is > a *bundle* of them ("IT Support staff"). A route gates on one operation, so it gates on a > **permission**. When you want the bundle, make a group and grant it several — groups nest, so a > group of groups works too. ### Naming a permission **Every permission name is `:`.** `scheduling:read`, `users:write`, `oauth2-clients:read`. Both halves are lowercase letters, digits, dashes and underscores; the admin plugin's create form refuses anything else, so the convention holds for whatever an operator adds later. - **``** names the thing acted on, not the plugin that happens to own it — permission names are one **global namespace**, so an operator grants `scheduling:read` once and every plugin referencing it is gated consistently. Pick a name no other plugin would claim for something else: `oauth2-clients`, not `clients`. - **``** names the operation. `read` and `write` cover most screens; use a more specific verb when the operation really is distinct (`invoices:approve`). A bare word is the mistake this rule exists to stop. `admin` says *who someone is*, not *what they may do* — that is a role, and roles are **groups** here. Split it by resource and action, then bundle it back up with a group if you want one grant to hand out several: ``` Group:it-support ──> Permission:users:read, Permission:users:write, Permission:groups:read, … ``` ### 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 → permissions: ["scheduling:read", "scheduling:write"] bob → permissions: ["scheduling:read"] carol → permissions: ["users:read", "users:write"] ``` Note what Carol does *not* have. **Permissions do not nest, and there is no superuser** — running the Users screen grants nothing on Groups, and nothing at all on `/scheduling`. `it-support` is the bundle; it is a **group**, not a permission. 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: he is in `support`, support's members are `staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in. Carol reads *and* writes users because `it-support` holds both halves, but the Groups screen is a different resource and she was never granted it. An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them on the page they asked for; a signed-in user who merely lacks the permission gets the 403 page, because 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 **Groups** and **Permissions** screens do exactly this, 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" } }' ``` Permissions are authored **only in Keto** — nothing else writes them, and a name exists only while some tuple carries it. Name yours [`:`](#naming-a-permission). 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. It is a separate mechanism — a `Resource` carries Keto `permits` (`view`, `edit`, `delete`, which nest 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, so a user rejected there never reaches the check. 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. This is the **authoritative reference** for the plugin API — the product's main surface. The contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types there are the single source of truth; the sections below explain them, the guarantees around them, and the rules the host enforces. A complete, runnable example lives in **[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend; the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding writes upstream, and a mix of public + permission-gated nav. It is **not** pre-installed — `plugins/` ships empty so you mount your own. To run it in dev, copy it in (`cp -r examples/plugins/scheduling plugins/scheduling`, then restart) — the dev compose already points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins//` and adapt. ### 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 ``` **Only `plugin.ts` is required.** The host looks for exactly that filename and its default-exported manifest. `views/`, `public/` and `i18n/` are the fixed folder *names* it resolves against — used only if the plugin renders views, serves assets or ships translations — but the files inside are yours to name (a catalog is named for its locale). Everything else (handlers, upstream clients, their filenames and folder layout) the host never sees; `plugin.ts` simply imports it. The `handlers.ts`/`service.ts` split above is just an example — name and arrange your modules however you like, or keep a routes-only plugin to a single `plugin.ts`. **Identity comes from the folder.** The folder name *is* the plugin `id`, and the mount path is `/` — neither is written in the manifest, so they can't drift or be claimed twice. The id must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name at discovery. The id also namespaces the plugin's `views/`, its `/public//` assets, and (by convention) its nav/permission names. A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`), the `oauth2` provider routes, and `public` (static). Since plugin routes resolve first, a folder claiming one would silently shadow a built-in route, so discovery refuses it loud (`RESERVED_PLUGIN_IDS`). (`/` is owned by the `home` field, not a route, so it needs no reservation; `admin` is **not** reserved — the admin screens are themselves a drop-in plugin mounted at `/admin`.) Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart." Nothing else references it; the operator stays in control through the central menu override (`config/menu.ts`). ### The manifest A plugin imports its host surface from one module — **`#plugin-api`** (a Node [subpath import](https://nodejs.org/api/packages.html#subpath-imports) mapped to `src/plugin-host/plugin-api.ts` in the root `package.json`), the **stable author barrel** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the body/CSRF/list-query helpers). Using `#plugin-api` (not a relative `../../src/...` path) means the same import works at any folder depth and survives host refactors — it resolves against the app's `package.json` wherever your plugin folder sits under it. That barrel *is* the contract boundary; don't reach into deeper `src/*` modules — the host may refactor those freely as long as the barrel holds. (Keep your plugin a plain folder — no `package.json` of its own — so `#plugin-api` resolves against the host's.) ```ts import { definePlugin } from "#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 and returns it unchanged — a manifest may equally be a plain typed object. It types the authored shape (`PluginManifest`); the host attaches the folder-derived `id` to produce the loaded `Plugin`. All validation happens at discovery. Note there is **no `id` or `basePath`** in the manifest — both come from the folder ([Anatomy](#anatomy-of-a-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 — see [Nav & permission gates](#nav--permission-gates)), and only then calls the handler with the [request context](#requestcontext). When the gate fails, an **anonymous** visitor is redirected to `/login` to sign in; the requested page is preserved as `return_to`, so after signing in they land **back on the page they asked for**, not the dashboard. A **signed-in** user who simply lacks the permission gets the **403** page. A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu items](#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 "#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, and an out-of-bounds name is refused. The template may `include()` the core building-block partials (app shell, nav tree, data table, …) and its own partials/subfolders to render a full page — exactly as the admin plugin's screens do. To load the plugin's own CSS, pass its `/public//x.css` href in the shell's `styles` slot (an array of extra stylesheet hrefs) — see the reference's `views/shifts.ejs`. - **Finer authorization than the route `permission`** uses the guards from `#plugin-api`: `requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect to sign in), `can(ctx, permission)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx, {namespace, object, relation})` (a live Keto check for relationship rules — the subject is the signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check` to render the 403 page. - The handler **fetches its own data** from upstream and renders it; plugins hold no state (see [Stateless](#stateless)). The partials only need rows. - `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`. #### Escaping & the trust boundary The host does not sandbox plugin output (crash-isolation is a non-goal), 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 (slot composition). Escape any untrusted content yourself before putting it there. - **Text is auto-escaped; URLs are not scheme-checked.** Partials escape text fields (labels, names), so those are injection-safe. But a URL field — nav `href`, a table cell link, a menu item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute: a `javascript:` or `data:` URL from upstream/user data becomes live XSS. When a URL comes from data you don't control, pass it through **`safeUrl()`** from `#plugin-api` first — it returns the URL when it's relative or `http(s):` and collapses anything else to `"#"`: ```ts import { safeUrl } from "#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 "#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/`. A `dashboard` handler renders against the native app shell via `ctx.chrome` exactly as a route handler does; a `home` handler is a **public** page, so `ctx.user` may be `null` (use it to show a "go to dashboard" link to a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on `/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link points there. For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null; branch on `ctx.permissions` *inside* to tailor the page per permission. Don't gate `dashboard` itself behind a single permission — there's no second dashboard to fall back to, so a user lacking it would land on a 403. (Both slots answer `GET` and `HEAD`.) Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a boot-stopping conflict ([below](#conflict-rules)), never last-write-wins. Neither needs a `routes` entry — the host mounts them above the `/` route namespace, and `/` can't be shadowed by a plugin route at all (route paths always carry the `/` prefix). ### 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 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`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, signInHref, theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among them, when that plugin is present) — already composed, permission-filtered, and current-marked for this request (the gated **Dashboard** link is omitted for an anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local — `brand`, `csrfToken`, `nav` (the rendered nav-tree), `signInHref`, `theme`, `user` — exactly as the reference `examples/plugins/scheduling/views/overview.ejs` does; a value you forget simply falls back to its shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the secret and sets the cookie; the plugin never touches it. It is **opt-in per handler** — a route that never calls it has no CSRF guard at all. (See the reference: `examples/plugins/scheduling/`.) The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the login/registration/front pages), so the menu looks identical signed in or out — it just permission-filters. A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the sidebar, single column); everything else still renders. **`ctx.t`** translates in the request's language, and the same block (`t`, `locale`, `locales`, `localeHref`, `dir`) is merged into every view's data — see [Languages](#languages-i18n). **`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log)) already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`, metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch` for upstream calls that adds a client span and propagates the trace (W3C `traceparent`) downstream. The barrel also exports a standalone **`tracedFetch`** (same behaviour, reads the ambient request log) to default an upstream client's `fetch` to — the reference plugin's `createUpstream` does exactly this, so its calls are traced with no per-handler wiring. Lines are correlated by a `requestId` and carry `service.name`; output/level/OTLP export are the host's config (it logs to console always, and to an OpenTelemetry Collector when `OTLP_ENDPOINT` is set). **Stability guarantee.** The fields above are the stable contract — present and non-breaking across a major `apiVersion`. New fields may be **added** within a major version (additive, never breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine, but prefer the typed fields so a handler keeps working as the host evolves. `user`/`permissions` come from the JWT middleware and are `null`/`[]` until a session exists. ### System capabilities (the `ctx.system` surface) Most plugins fetch their own data from an upstream service they configure ([the scheduling reference](examples/plugins/scheduling/) points `SCHEDULING_UPSTREAM` at its backend). A **system plugin** — one that administers *Plainpages' own* identity stack rather than a domain service — 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 `#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 (Kratos/Keto configured, Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system plugin treats every field as optional and **degrades when absent** — the host never fails a request over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Permissions use `ctx.system.keto`, OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user permission-change calls `ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing the screen renders a themed 503. This is a **privileged** surface — it hands a plugin the keys to identity and authorization. It's meant for first-party system plugins you author or vendor, the same trust level as any plugin (the host doesn't sandbox — [crash-isolation is a non-goal](#overview)). 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's permissions include that name. Use arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a **Lucide icon**, referenced by its 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. #### Public pages & menu items A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**, and the menu item shows for everyone. This is the same as omitting `permission` (an ungated route/node is already open) but stated outright, so "public" is a **deliberate choice, not the accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring both is contradictory and discovery refuses the plugin at boot. A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor `ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page) in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.permissions` is empty (read a permission with `can(ctx, …)` to branch). The reference plugin's `/scheduling` **Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone, while the actual shifts list stays behind `scheduling:read`. The gate passes iff the user's JWT `permissions` include that name. How permissions are granted, why their names are a shared global namespace, and the fine-grained per-row tier are all covered in [Users, groups & permissions](#users-groups--permissions). Declaring the ones you gate on in `permissions` is **optional but recommended**: it documents them, feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is granted every discovered plugin's declared permissions, so a dropped-in plugin works out of the box without editing host config. ### Contract versioning Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built against — and the host exposes the current `HOST_API_VERSION` (e.g. `"1.0.0"`). The host bumps **major** on a breaking manifest/handler change and **minor** on an additive one. At discovery the host parses both with `parseSemver` (the official semver core regex — 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 — in keeping with the project's pinning rules); the *host* supplies the caret-style compatibility. `parseSemver`/`checkApiVersion` are tight, dependency-free functions (the `semver` package's ranges/coercion/prerelease-precedence are more than the contract needs). ### Conflict rules Plugins are independent folders, so 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. | There is **no separate `basePath` rule**: the mount path is the derived `/`, so its uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns rather than aborts; everything else is an error an author fixes before the host will start. Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)). ### 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` wins and short-circuits — later `onRequest` hooks and the route handler are skipped, and that result renders against its own plugin's views. `onResponse` runs for a matched route after its handler, with the handler's result; its return value is ignored. Hooks run with no sandbox — a throwing hook fails loud (boot for `onBoot`, the request for the others). Keep them cheap; `onRequest` is on the hot path (the host skips the pipeline entirely when no plugin declares a hook). This surface is intentionally small and may grow additively within the major version. ### 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. There are two ways, depending on where the plugin's source lives: **1. In your clone (the default dev loop).** Create `plugins//` in the working tree. `docker compose up` already bind-mounts the whole tree (`compose.override.yml`: `.:/app`), so the folder is live in the container — restart to pick it up. This is the [Quick-start](#quick-start) path. **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 or volume container works the same way (target `/app/plugins/`), but a bind mount matches the edit-and-reload loop. For a **baked** production image, just keep the plugin in the build context and it's `COPY`'d in at build time — pinned and reproducible; mount a volume only to add plugins to an already-built image. `#plugin-api` resolves against the *nearest* `package.json`, which at runtime must be the host's at `/app` — so the mounted `plugins//` folder must **not** contain a `package.json` of its own (one there becomes the plugin's scope, lacks the `#plugin-api` mapping, and boot fails loud). A plugin kept in its own repo therefore mounts as just its subfolder, with the repo's `package.json` kept outside the mount. To typecheck it against the barrel there, typecheck it mounted under the host tree, or vendor a type stub of the barrel and map `#plugin-api` to that (an `imports` target can't escape its own package scope, so it can't point at the host's file directly). > Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and > validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad > plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts > each route at `/`, resolves `:name` params, runs the permission gate, and turns the > handler's `RouteResult` into the response; a `view` result renders > `plugins//views/.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core > building-block partials. A plugin's `public/` assets are served at `/public//` > (`src/http/static.ts`). The mount mechanics above are how the files get into the container > either way. ### Local dev & test story A plugin is a normal folder of TypeScript, so an author tests it the same way the core is tested — everything in Docker, no host tooling. The reference example (`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` (no host). 1. **Unit-test handlers as pure functions.** Keep a handler thin: parse `ctx`, fetch upstream, return a `RouteResult`. Test the data-shaping in isolation (mock `fetch`/upstream) with `node --test`, exactly like `src/ui/dashboard.test.ts` tests the dashboard model. No host needed. ```bash docker compose run --rm web npm test ``` 2. **Run one plugin against the host.** Get the folder into the container's `/app/plugins/` — either in your clone (the dev compose bind-mounts the tree) or by bind-mounting an external folder ([Where plugins live](#where-plugins-live-and-how-to-mount-them)) — and `docker compose up`; the host discovers it. For an isolated harness, the host exposes plugin injection (`createApp({ plugins: [myPlugin] })`) so a test can mount a single manifest and 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 so the suite stays `fullyParallel`, run against the live `web` service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`; its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra). The validation an author hits is the same the host runs: bad `apiVersion` or a conflict ([Conflict rules](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved. ## 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 — or `#menu-config` resolves against that instead and boot fails loud): ```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** by reading the permissions in the session JWT (no per-request authz call — see [Auth, sessions & access](#auth-sessions--access)), so the menu only ever shows what that person can reach. An item (or a whole page) may instead be marked **`public: true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose a public page and its menu entry (an ungated item is already public; `public` just says so on purpose, and is mutually exclusive with `permission`). The markup is the recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static, counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar brand shows the configured logo (else a default mark), and the theme sets the theme-switch default. **One menu, one shell, everywhere.** There is a single menu (`src/ui/chrome.ts` `buildPluginChrome`), rendered by the same app shell on **every** page — the dashboard, plugin pages (the admin plugin's screens included), and the login / registration / recovery / front (`/`) pages. So it looks identical signed in or out; it just shows fewer items to an anonymous visitor (only `public` ones, plus a Sign-in link), filtered by the same per-user rule. The sidebar collapses to a burger on a narrow screen. A page that wants a focused, chrome-free layout (e.g. a print view) opts out with the shell's `menu: false`. ## Building blocks Plainpages is a **component library, not a page generator** — you assemble pages from partials and helpers rather than declaring a schema and getting magic. The vocabulary is a set of 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 a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a session — a `GuardError` the host turns into a redirect to sign in), `can(permission)` (a coarse JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for relationship rules). ## 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 `