Files
plainpages/docs/plugin-contract.md

25 KiB
Raw Permalink Blame History

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/<id>/views/, with the core partials reachable via include()), per-plugin static serving (/public/<id>/ → 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 and in use by the built-in screens and the reference plugin. Later phases extended this contract: the replaceable landing pages and public pages & menu items (§10), both documented below.

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 az, 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.

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 admin screens, 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.)

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 — src/plugin-api.ts, the stable author barrel (definePlugin, the manifest/handler types, RequestContext, the guards, and the body/CSRF/list-query helpers). 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.

import { definePlugin } from "../../src/plugin-api.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. Declared for documentation, conflict detection, and
  // bootstrap seeding (the demo admin is granted every discovered plugin's tokens). 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.
home no A RouteHandler that owns the public landing /. At most one plugin may declare it. See The landing pages.
dashboard no A RouteHandler that owns the gated app home /dashboard. At most one plugin may declare it. See The landing pages.
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 docs, conflict detection, and bootstrap seeding (see Nav & permissions).
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?, public?, 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. When the gate fails, an anonymous visitor is redirected to /login to sign in (same as the built-in admin screens); 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 role gets the 403 page. A route marked public: true has no gate at all — anyone reaches it (see 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.

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 { parseListQuery, type RequestContext } from "../../src/plugin-api.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. To load the plugin's own CSS, pass its /public/<id>/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 src/plugin-api.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, pass it through safeUrl() from src/plugin-api.ts first — it returns the URL when it's relative or http(s): and collapses anything else to "#":
    import { safeUrl } from "../../src/plugin-api.ts";
    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.
import { definePlugin } from "../../src/plugin-api.ts";
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 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.roles inside to tailor the page per role. 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), never last-write-wins. Neither needs a routes entry — the host mounts them above the /<id> route namespace, and / can't be shadowed by a plugin route at all (route paths always carry the /<id> prefix).

RequestContext

Every handler receives one argument, the RequestContext (src/context.ts), built once per request:

interface RequestContext {
  chrome: PageChrome;                // brand/global-nav/user/theme/csrf for the native app shell
  log: Log;                          // request-scoped logger, in this request's trace (§9)
  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
  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 as the built-in screens); chrome.nav is the global menu — your plugin's nav fragment plus the others and the admin section — already composed, role-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 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. (See the reference: plugins/scheduling/.)

ctx.log is a structured, request-scoped logger (@larvit/log, §9) 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/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 is public, 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.

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 (a no-permission 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.roles is empty (read a role 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.

A permission token is a coarse role. The route/nav gate passes iff the user's JWT roles include the token; those roles come from Keto at login, so an operator grants a token by writing the Keto tuple Role:<token>#members@user:<id> (or to a group) — the admin Roles screen does this. (The fine-grained, per-row tier is the separate Keto Resource namespace — see the README's Three tiers of "may I?"; it is not what a route permission checks.)

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, feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens (§3), 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).

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.
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).
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.

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).

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. The shipped reference (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/dashboard.test.ts tests the dashboard model. No host needed.

    docker compose run --rm web npm test
    
  2. 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) — 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, ship a side-effect-free Playwright test in e2e/ 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 §8 full-E2E item (needs cross-host login infra).

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.