# 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: "0.4.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 - [Goals](#goals) - [Audience](#audience) - [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) - [settings](#plugin-settings) - [storage](#plugin-storage) - [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 core](#stateless-core) - [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) ## Goals 1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at its own domain, not at sign-in, a menu or a table. - Signal: a new plugin reaches a working, gated page with a menu, tables, filters and forms built only from the core building blocks. - Counter-signal: the core grows features only one project needs. - Why: boilerplate removed by absorbing a project's domain only moves it into the core. 2. **Achieve:** Every building block works for the person using it: a non-technical user gets what they meant on the first try, and can't hurt themselves by accident. - Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted code being refused, a double click acting twice, or an admin locking themselves out. - Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to know about. - Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every plugin author. 3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI building blocks (menu, tables, filters, forms) and the plugin structure. Everything else is a plugin. 4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript, on old and embedded browsers too. A newer browser only makes it nicer. 5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the database, never in local storage or JavaScript. A cookie carries only what must be bound to the browser: the session and its CSRF token. 6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS styles meaning, not looks. That keeps every page accessible. 7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way. - Signal: a plugin replaces any page, partial, menu entry or string without forking the host, and fewer plugins need a host change to get unblocked. - Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake shows up at runtime instead of at boot. - Why: freedom that breaks silently costs more than the boilerplate it saved. 8. **Achieve:** Speed: pages render fast and stay small. - Signal: server time per page and bytes sent per page fall. - Counter-signal: a building block loses a feature or accessibility to save bytes. - Why: a fast page that does less only moves the work back into each plugin. 9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the examples alone. - Signal: an agent given only those writes a plugin that passes discovery and its E2E test on the first try. - Counter-signal: the README grows to restate what the code already shows. - Why: docs that pass the test by growing cost every later reader, human or LLM. 10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every environment. ## Audience - **Developers starting a web GUI project, and the LLMs that build plugins with them.** They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises, all versioned by `apiVersion`. A change that breaks a plugin takes a major bump. - **Operators** who self-host Plainpages. They may rely on semver-tagged images, on configuration through environment variables and the drop-in mounts (`plugins/`, `config/`, `locales/`), and on upgrades within a minor version that need no change on their side. - **Visitors** to a site built on Plainpages, on any browser, including the older engines in TVs, kiosks, thin clients and embedded panels. They may rely on pages that load fast and work without JavaScript, with a keyboard and a screen reader. ## 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. 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 the host stores nothing of its own. **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/mine` | `session: true` | ✅ | ✅ | ✅ | → `/login` | | `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: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning) // Nav fragment, merged into the global menu and gate-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). The gate runs 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). Every node names [exactly one gate](#public-pages--menu-items). `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). | | `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING__` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). | | `storage` | no | `true` ⇒ the host provisions a Postgres database and login role for this plugin and hands the credentials to `onBoot`. See [Plugin storage](#plugin-storage). | A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional. ### Routes & handlers A route is `{ method, path, handler }` plus [exactly one gate](#public-pages--menu-items) — `permission`, `public: true` or `session: true`. `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 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. `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, or from the plugin's own [storage](#plugin-storage); the host holds none of it (see [Stateless core](#stateless-core)). - 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: "0.4.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`, is `session` and someone is signed in, or names a `permission` the user holds. 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, mark the header `public: true` — it then gates nothing, each child decides, and `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 a 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 — open stated outright, so it is a deliberate choice rather than a forgotten gate. **`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a permission gate does. Every route and nav node names **exactly one** of the three, spelled `true` (or a permission name). Naming none, naming two, or spelling one `false` is refused at boot — so a forgotten gate fails the plugin instead of publishing a page. 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 **Plainpages release** it was built against. The host's `HOST_API_VERSION` *is* its release version, so a plugin author reads one version off the image they run and writes it down — there is no separate contract number. Both release paths refuse a tag whose `major.minor` disagrees with the constant, so the two cannot drift. Patch releases are invisible here — `checkApiVersion` ignores the patch digit, which is what lets dependency updates ship continuously without touching any plugin. At discovery the host parses both versions 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 | | **major `0`**, plugin minor **<** host minor | `refuse` | **abort boot** — pre-1.0 the minor is the breaking slot | | same major, plugin minor **<** host minor | `warn` | load, log — built against an older release; check that release's notes | | 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 compatibility. One digit carries the whole release, so a **minor** means either the plugin contract changed or a dependency moved far enough to warrant one. [`CHANGELOG.md`](CHANGELOG.md) is what a minor sends you to: what broke, and what to change. ### 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 that does not name [exactly one gate](#public-pages--menu-items). ### Hooks Optional, for reacting to system actions. A plugin's `hooks` may implement: | Hook | When | May | | --- | --- | --- | | `onBoot(host)` | after discovery, before the server listens | warm caches, validate upstream config, open a [storage](#plugin-storage) connection | | `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 | `onBoot`'s `host` is a `BootContext`, carrying `storage` for a plugin that declared it. A hook written without a parameter stays valid. 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. A plugin folder is code, not data — 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. ### Plugin settings A plugin declares the configuration it accepts, and the host resolves it from the environment at boot. Each key becomes one variable — `PLUGIN_SETTING__`, the id's dashes and the key's camel humps both becoming underscores — so `upstream` on the `scheduling` plugin is set by `PLUGIN_SETTING_SCHEDULING_UPSTREAM`. ```ts export default definePlugin({ apiVersion: "0.4.0", settings: [ { key: "upstream", type: "url", required: true, description: "Base URL of the backend" }, { key: "pageSize", type: "number", default: 25 }, { key: "mode", type: "enum", values: ["strict", "lenient"], default: "strict" }, { key: "apiKey", type: "string", secret: true, default: "dev-insecure-key" }, ], hooks: { onBoot: ({ settings }) => { settings.upstream; // string — required, so the boot already refused without it settings.pageSize; // number — defaulted, so always present start(settings); }, }, }); ``` `type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is coerced and checked at boot, so a mistyped value names the plugin, the key and the variable instead of surfacing later as a broken page. **`required` and `default` are mutually exclusive** — a default means the setting can never fail, so declaring both is refused at discovery. That leaves three cases, and the type `onBoot` receives follows them exactly: `required: true` is always present, a `default` is always present, and a setting with neither is `T | undefined`, so the plugin has to handle its absence. **Secrets.** `secret: true` marks a value the host reads but never renders — not in a boot log, not in an error, not on the admin screen, which shows only whether it resolved and from where. With `REQUIRE_SECURE_SECRETS=true` a secret that is unset, or still equal to its declared default, refuses the boot — the same rule the host applies to its own secrets. **Where it fails, and where it warns.** A malformed declaration is refused at discovery; a missing `required` value or a value that will not coerce refuses the boot. A `PLUGIN_SETTING_` variable no installed plugin declares is only *reported* — it is usually a typo in the one the operator meant to set, and naming it turns two unrelated-looking errors into one. Declaring settings without an `onBoot` warns too: they resolve, but nothing receives them. **Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen (`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets each, and whether the value came from the environment or the declared default. ### Plugin storage A plugin that needs to keep data sets `storage: true`. The host then provisions a Postgres **database and login role of its own** — both named `plugin_` — and hands the credentials to `onBoot`: ```ts import postgres from "postgres"; // your dependency, not the host's import { definePlugin } from "@plainpages/plugin-api"; let sql: ReturnType; export default definePlugin({ apiVersion: "0.4.0", storage: true, hooks: { onBoot: async (boot) => { if (!boot.storage) throw new Error("things: storage was not provisioned"); sql = postgres(boot.storage.url); // Every web instance runs onBoot, and concurrent CREATE TABLE IF NOT EXISTS is an error in // Postgres — the lock is released when the transaction ends. await sql.begin(async (tx) => { await tx`SELECT pg_advisory_xact_lock(hashtext('things:schema'))`; await tx`CREATE TABLE IF NOT EXISTS things (id uuid PRIMARY KEY, name text NOT NULL)`; }); }, }, }); ``` `boot.storage` is a `StorageCredentials` — `database`, `host`, `password`, `port`, `user`, and `url`, the same values pre-assembled as a DSN, which most clients take directly. It is typed optional, so the guard above is expected of every storage plugin rather than a sign something is wrong. **Credentials, not a client.** The host has no opinion on how you reach Postgres: depend on `postgres`, `pg`, a query builder or an ORM ([Plugin dependencies](#plugin-dependencies)). No driver is part of the contract, so upgrading yours is yours alone to time. The flip side is that pool sizing is yours too — keep yours under `PLUGIN_DB_CONNECTION_LIMIT` (default 10), the per-role ceiling the host sets so one plugin cannot exhaust the Postgres this stack shares with Ory. **The schema is yours, migrations included.** The host creates the database empty, never reads or writes inside it, and ships no migration machinery — evolving your tables compatibly (expand, then contract, so a rolled-back version still runs) is yours to own. Create your tables in `onBoot`: it runs before the server listens, so a failure aborts boot instead of surfacing later as a broken page. What the host does guarantee: - **One database and one role per plugin**, with `CONNECT` revoked from `PUBLIC`. This bounds *accidents* — a wrong database name, a mistyped DSN, a stray query — and it is not a security boundary: plugins share the `web` process, so a plugin that goes looking can reach another's credentials. Install plugins you trust ([Security model](#security-model)). - **Provisioning is idempotent and runs every boot**, so a plugin dropped in later is picked up by the next `docker compose up -d` — the same rule as permission seeding. Each boot re-applies the role's password, connection limit, and `NOCREATEDB`/`NOCREATEROLE`. - **Your data is never dropped.** Removing a plugin folder leaves its database untouched; deleting it is a deliberate act by an operator. Each boot logs any `plugin_*` database no installed plugin claims, so what you left behind stays findable — read that list before dropping anything, since a second Plainpages stack sharing this server will have its databases named there too. **Passwords are derived, never stored** — each is `HMAC-SHA256(PLUGIN_DB_SECRET, )`, so `bootstrap` and `web` compute the same value independently and nothing has to be written down. Rotate every plugin's password by changing `PLUGIN_DB_SECRET` and running `docker compose up -d`, which re-applies each role's password and leaves the data alone — restart every `web` instance as part of it, since one still holding the old secret can open no new connections. Treat the secret as you would a database password: whoever holds it holds every plugin database. Under `REQUIRE_SECURE_SECRETS` a missing, empty or throwaway secret is refused — in `bootstrap` before it creates any role, so no database is ever given a password derivable from a constant in this repo. **Only `bootstrap` holds provisioning credentials.** It alone gets `PLUGIN_DB_ADMIN_URL`, an account with `CREATEDB` and `CREATEROLE` (superuser works but is more than it needs; the dev stack simply reuses Ory's). Keep using the same account: Postgres gives a `CREATEROLE` account admin rights only over the roles it created itself, so if you swap it for a fresh one, grant that one `ADMIN OPTION` on the existing `plugin_*` roles first, or the next boot cannot re-apply their passwords. `web` gets `PLUGIN_DB_URL`, which names the server and must carry no credentials — supply one with a username or password and boot fails, rather than leaving a privileged password in the process that runs plugin code. Set both, plus `PLUGIN_DB_SECRET` ([Configuration](#configuration)); the dev stack sets them for you. Storage stays off until `PLUGIN_DB_URL` is set, and a plugin declaring it while that is unset **aborts boot** naming itself — rather than serving pages without its data. One naming limit: a storage plugin's folder may be at most **56 characters**, so `plugin_` fits Postgres' 63-byte identifier. ### 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 node a **plugin** declares names one gate — a `permission`, **`public: true`** (everyone) or **`session: true`** (anyone signed in); a header this override groups takes none, and shows whenever a child does. 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. 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`. **The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is reachable below the fold without adding a scroll region of its own, and browser paging, scroll restoration and find-in-page work without a page doing anything. A plugin that wants a full-height pane owns that in its own stylesheet. ## 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 `