# The Plainpages plugin contract The authoritative reference for the plugin API — the product's main surface. A plugin is a self-contained folder under `plugins/` that the host discovers at boot; there is no registration step. The contract is **TypeScript** (`src/plugin.ts`), so the types here are the single source of truth — this document explains them, the guarantees around them, and the rules the host enforces. **Design stance.** The audience is experienced developers. The API 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 *non-goal* — diagnose at deploy time, not in production. > **Status.** This is the contract the §2 host implements. The types and pure rules > (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery** > (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params, > permission gate, `RouteResult` → response), and the **per-plugin view resolver** > (`src/view-resolver.ts` — a `view` result renders `plugins//views/`, with the core partials > reachable via `include()`), **per-plugin static serving** (`/public//` → the plugin's > `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding** > (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme — > rendered in the app shell) are wired. The §2 plugin host is feature-complete; the remaining §2 > items are a project-wide review and comment/test cleanup. ## Anatomy of a plugin ``` plugins/scheduling/ # folder name = the plugin id → mounted at /scheduling plugin.ts # default export: the manifest (definePlugin(...)) shifts.ts # handlers, helpers — plain modules views/ # EJS templates for this plugin's pages shifts.ejs public/ # static assets, served at /public/scheduling/ scheduling.css ``` **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 tokens. A handful of ids are **reserved** for the host's own first-party mounts (`auth`, `login`, `logout`, `public`, `recovery`, `registration`, `settings`, `verification`); since plugin routes resolve first, a folder claiming one would silently shadow a built-in route, so discovery refuses it loud (`RESERVED_PLUGIN_IDS`). 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 ```ts import { definePlugin } from "../../src/plugin.ts"; import { listShifts, createShift } from "./shifts.ts"; export default definePlugin({ apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see Versioning) // Nav fragment, merged into the global menu and permission-filtered per user. // `icon` is a Lucide icon by its sprite id (src/icons.ts). nav: [{ icon: "i-cal", id: "scheduling:root", label: "Scheduling", children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], }], // Permission tokens this plugin introduces (for docs + Keto seeding). Optional. permissions: [ { token: "scheduling:read", description: "View shifts" }, { token: "scheduling:write", description: "Create and edit shifts" }, ], // Route handlers, mounted under the plugin's path (/scheduling). `permission` gates first. routes: [ { method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts }, { method: "POST", path: "/shifts", permission: "scheduling:write", handler: createShift }, ], }); ``` `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 the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. | | `permissions` | no | Tokens this plugin introduces; declared for documentation and seeding. | | `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?, handler }`. `path` is **relative to the plugin's mount path `/`** (so `/shifts` in the `scheduling` plugin serves `/scheduling/shifts`); 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 the README), and only then calls the handler with the [request context](#requestcontext). `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 type RouteResult = | { view: string; data?: Record; status?: number; headers?: Record } | { html: string; status?: number; headers?: Record } | { json: unknown; status?: number; headers?: Record } // opt-in JS enhancement | { redirect: string; status?: number }; // 303 unless status set ``` ```ts // shifts.ts import type { RequestContext } from "../../src/context.ts"; import { parseListQuery } from "../../src/list-query.ts"; export async function listShifts(ctx: RequestContext) { const q = parseListQuery(ctx.url); const rows = await fetch(`${upstream}/shifts?${ctx.url.searchParams}`).then((r) => r.json()); return { view: "shifts", data: { rows, q } }; // renders plugins/scheduling/views/shifts.ejs } ``` - **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names like `"shifts/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 built-in screens do. - **Finer authorization than the route `permission`** uses the guards in `src/guards.ts`: `requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect to sign in), `can(ctx, role)` (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 the README's *Stateless* section). 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, restrict it to a relative (`/`, `?`, `#`) or `http(s):` URL before handing it to a partial. (A shared `safeUrl()` helper will land with the first plugin that renders untrusted URL data, §5/§7.) ## RequestContext Every handler receives one argument, the `RequestContext` (`src/context.ts`), built once per request: ```ts interface RequestContext { params: Record; // path params from the route match, e.g. /shifts/:id → { id } query: URLSearchParams; // alias of url.searchParams req: IncomingMessage; res: ServerResponse; roles: string[]; // user?.roles ?? [] — coarse gate without a null-check url: URL; user: User | null; // { id, email, roles } from the verified session JWT, or null } ``` **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`/`roles` come from the §4 JWT middleware and are `null`/`[]` until a session exists. ## Nav & permissions A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/nav.ts`), which applies the central override and then **filters per user** by the roles in the session JWT — a node shows iff it declares no `permission` or the user's roles include that token. 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/icons.ts`, and adding one means registering its lucide name there. Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants `scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace your tokens as `:` to avoid accidental clashes. Declaring them in `permissions` is optional but recommended (it documents them and lets the bootstrap seed Keto, §3). ## 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). **Write a literal, never `HOST_API_VERSION`.** `apiVersion` records the version the plugin was *built against*. Importing the host's current constant would make every plugin always equal the host — the check could never fire, and a future breaking change would slip through silently. ## 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. | | `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `:` 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. ## 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. ## 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. 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/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 (README → *Where plugins live*) — and `docker compose up`; the host discovers it. For an isolated harness, the §2 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, every plugin page/form ships *with* a Playwright test in `e2e/`, side-effect-free so the suite stays `fullyParallel`. The test runs against the live `web` service with the plugin mounted. The validation an author hits is the same the host runs: bad `apiVersion` or a conflict ([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.