§7 reference plugin (todo §7); plugins/scheduling is the worked example of the plugin contract — a list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, permission-gated nav. shifts.ts: an injectable-fetch upstream REST client (stateless stand-in for the customer backend) + thin handler factories (list filters by ?q + degrades to a recoverable page on upstream-down; create CSRF-guards via ctx.verifyCsrf, validates, forwards, PRG, 502 on upstream 4xx). plugin.ts: apiVersion literal, namespaced scheduling:read/write perms, nav gated so the whole Scheduling header vanishes for non-holders. Views compose the core building blocks around the native app shell, incl. the plugin's own partials/shift-form. New host capability so a plugin page is native + secure (src/chrome.ts buildPluginChrome): ctx.chrome = brand/global-nav/user/theme/csrf for partials/shell (global menu = Dashboard + every plugin nav fragment + gated admin section, role-filtered + current-marked); ctx.verifyCsrf = the host's bound double-submit verifier (secret stays in the host). Both added to RequestContext (defaulted in buildContext), built per plugin route in app.ts (CSRF cookie set when fresh). Dashboard merges plugin nav fragments too (gated => invisible to anonymous, visual E2E byte-identical). Out of the box: bootstrap grants the demo admin scheduling:read/write (seedAdmin generalized to a roles list, env ADMIN_ROLES); dev compose runs a tiny stdlib mock upstream (examples/shifts-upstream, SCHEDULING_UPSTREAM). plugins/ added to tsconfig + the npm test glob. Tests-first across shifts/chrome/app/dashboard/bootstrap. README Building-a-plugin + Layout and docs/plugin-contract.md (ctx.chrome/verifyCsrf, upstream pattern) updated. typecheck + 296 units + the Ory-free visual E2E green (plugin discovered at boot, routes/nav gated, dashboard unchanged); live full-stack boot-verified (stack up with plugin + mock upstream serving the seeded shifts, bootstrap grants in real Keto all allowed:true) then torn down. apiVersion stays 1.0.0 (contract still assembled in §7). Authenticated browser happy-path deferred to §8 full E2E (line 114).

This commit is contained in:
2026-06-19 14:48:27 +02:00
parent ec7dcafecd
commit f189f88942
25 changed files with 820 additions and 39 deletions

View File

@@ -168,6 +168,7 @@ request:
```ts
interface RequestContext {
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
params: Record<string, string>; // path params from the route match, e.g. /shifts/:id → { id }
query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage;
@@ -175,9 +176,19 @@ interface RequestContext {
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, 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. **`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/`.)
**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,
@@ -262,7 +273,9 @@ 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.
— 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