16 KiB
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 insrc/plugin.ts; discovery (src/discovery.ts), the router (src/router.ts— method+path match,:nameparams, permission gate,RouteResult→ response), and the per-plugin view resolver (src/view-resolver.ts— aviewresult rendersplugins/<id>/views/, with the core partials reachable viainclude()), per-plugin static serving (/public/<id>/→ the plugin'spublic/,routePublicinsrc/static.ts), and the central menu override + branding (config/menu.ts, loaded bysrc/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
/<id> — 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/<id>/ assets, and (by
convention) its nav/permission tokens.
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
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).
| Field | Required | Notes |
|---|---|---|
apiVersion |
yes | Semver the plugin was built against — a literal, not HOST_API_VERSION. See Versioning. |
nav |
no | NavNode[] fragment (same shape composeNav consumes). icon is a Lucide sprite id (src/icons.ts); node ids must be globally unique. |
permissions |
no | Tokens this plugin introduces; declared for documentation and seeding. |
routes |
no | See Routes & handlers. |
hooks |
no | See 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 /<id> (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.
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.
type RouteResult =
| { view: string; data?: Record<string, unknown>; status?: number; headers?: Record<string, string> }
| { html: string; status?: number; headers?: Record<string, string> }
| { json: unknown; status?: number; headers?: Record<string, string> } // opt-in JS enhancement
| { redirect: string; status?: number }; // 303 unless status set
// 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
}
viewresolves against the plugin's ownviews/(src/view-resolver.ts) — nested names like"shifts/edit"work, and an out-of-bounds name is refused. The template mayinclude()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
permissionuses the guards insrc/guards.ts:requireSession(ctx)(assert a session — throws aGuardErrorthe host turns into a redirect to sign in),can(ctx, role)(a coarse JWT-claim check, zero I/O), andcheck(keto, ctx, {namespace, object, relation})(a live Keto check for relationship rules — the subject is the signed-in user, anonymous ⇒ denied). Thrownew GuardError(403, …)after a failedcan/checkto 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.
defaultstatus:200forview/html/json,303forredirect.
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*.htmlpartial fields (cell.html,error.html, a menutrigger.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: ajavascript:ordata:URL from upstream/user data becomes live XSS. When a URL comes from data you don't control, restrict it to a relative (/,?,#) orhttp(s):URL before handing it to a partial. (A sharedsafeUrl()helper lands with the data-driven plugins in §4.)
RequestContext
Every handler receives one argument, the RequestContext (src/context.ts), built once per
request:
interface RequestContext {
params: Record<string, string>; // 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 <id>:<action> 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 /<id> 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 <id>:<action> if unintended. |
There is no separate basePath rule: the mount path is the derived /<id>, 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.
-
Unit-test handlers as pure functions. Keep a handler thin: parse
ctx, fetch upstream, return aRouteResult. Test the data-shaping in isolation (mockfetch/upstream) withnode --test, exactly likesrc/dashboard.test.tstests the dashboard model. No host needed.docker compose run --rm web npm test -
Run one plugin against the host. Get the folder into the container's
/app/plugins/<id>— either in your clone (the dev compose bind-mounts the tree) or by bind-mounting an external folder (README → Where plugins live) — anddocker 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. -
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 staysfullyParallel. The test runs against the livewebservice with the plugin mounted.
The validation an author hits is the same the host runs: bad apiVersion or a conflict
(above) stops boot with a precise message naming the plugin(s) involved.