§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

@@ -0,0 +1,31 @@
# Scheduling — the reference plugin
A worked example of the [plugin contract](../../docs/plugin-contract.md). Copy this folder, rename
it (the folder name becomes the plugin id and mount path), and point it at your own backend.
What it demonstrates:
- **A list page that fetches upstream data** — `GET /scheduling/shifts` calls the upstream REST
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
data-table). Search round-trips the URL; zero-JS.
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
reusing the core `field` partial.
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
`fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`).
## Upstream
Set `SCHEDULING_UPSTREAM` to your backend's base URL (it must expose `GET /shifts` and
`POST /shifts`). The dev compose points it at a tiny in-memory mock (`examples/shifts-upstream/`)
so `docker compose up` shows the plugin working out of the box.
## Granting access
A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write`
to create). The one-command bootstrap grants both to the demo admin, so the seeded
`admin@plainpages.local` can use it immediately.

View File

@@ -0,0 +1,36 @@
// Reference plugin (todo §7): a worked example of the contract — a list page that fetches upstream
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md.
import { definePlugin } from "../../src/plugin.ts";
import { createShift, createUpstream, listShifts, newShiftForm, READ, SHIFTS_PATH, WRITE } from "./shifts.ts";
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
const upstream = createUpstream(process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000");
export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// Merged into the global menu + filtered per user: the "Shifts" leaf shows only for a user holding
// `scheduling:read`, so the whole "Scheduling" header disappears for everyone else.
nav: [{
children: [{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ }],
icon: "i-cal",
id: "scheduling",
label: "Scheduling",
}],
// Tokens this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
permissions: [
{ description: "View shifts", token: READ },
{ description: "Create and edit shifts", token: WRITE },
],
// Mounted under /scheduling; `permission` gates before the handler runs.
routes: [
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
],
});

View File

@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import test from "node:test";
import type { PageChrome } from "../../src/chrome.ts";
import type { RequestContext } from "../../src/context.ts";
import { GuardError } from "../../src/guards.ts";
import type { RouteResult } from "../../src/plugin.ts";
import {
buildFormModel, createShift, createUpstream, listShifts, newShiftForm, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts";
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], user: { email: "", initials: "T", name: "Tester" } };
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return {
chrome: CHROME, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true),
};
}
const SHIFTS: Shift[] = [
{ assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
{ assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
];
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
const asView = (r: RouteResult | void) => {
assert.ok(r && "view" in r, "expected a view result");
return r as { data: Record<string, unknown>; status?: number; view: string };
};
// ---- upstream client (fetch injected) ----
test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", async () => {
let seen = "";
const http = (async (url, init) => {
seen = String(url);
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
}) as typeof fetch;
const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed
assert.equal(seen, "http://up:4000/shifts");
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
});
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
});
test("createUpstream.create POSTs the input as JSON", async () => {
let body: unknown, method = "";
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
await createUpstream("http://up:4000", http).create(input);
assert.equal(method, "POST");
assert.deepEqual(body, input);
});
// ---- input + validation ----
test("readInput trims; validate requires title + assignee", () => {
assert.deepEqual(readInput(new URLSearchParams("title=%20Shift%20&assignee=Bo&start=1&end=2")), { assignee: "Bo", end: "2", start: "1", title: "Shift" });
assert.equal(validate({ assignee: "Bo", end: "", start: "", title: "Shift" }), null);
assert.deepEqual(Object.keys(validate({ assignee: "", end: "", start: "", title: "" }) ?? {}), ["title", "assignee"]);
});
// ---- list handler ----
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
assert.equal(r.view, "shifts");
const table = r.data["table"] as { rows: { name: string }[] };
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
assert.equal(r.data["canWrite"], true);
assert.equal(r.data["chrome"], CHROME);
const filtered = asView(await listShifts(fakeUpstream())(fakeCtx({ url: "http://localhost/scheduling/shifts?q=afternoon" })));
assert.deepEqual((filtered.data["table"] as { rows: { name: string }[] }).rows.map((x) => x.name), ["Afternoon support"]);
assert.equal(filtered.data["canWrite"], false); // no scheduling:write
});
test("listShifts degrades to a recoverable error page when the upstream is down (no throw)", async () => {
const r = asView(await listShifts(fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } }))(fakeCtx()));
assert.match(String(r.data["error"]), /scheduling service/i);
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
});
// ---- create handler ----
test("newShiftForm renders the empty form", async () => {
const r = asView(await newShiftForm()(fakeCtx()));
assert.equal(r.view, "shift-new");
assert.equal((r.data["form"] as { csrfToken: string }).csrfToken, "tok");
});
test("createShift rejects a bad CSRF token with a 403 GuardError", async () => {
await assert.rejects(
async () => { await createShift(fakeUpstream())(fakeCtx({ body: "title=T&assignee=A", verifyCsrf: () => false })); },
(e: unknown) => e instanceof GuardError && e.status === 403,
);
});
test("createShift re-renders the form (400) on a validation error, never touching the upstream", async () => {
let created = false;
const r = asView(await createShift(fakeUpstream({ create: async () => { created = true; } }))(fakeCtx({ body: "title=&assignee=" })));
assert.equal(r.status, 400);
assert.equal(r.view, "shift-new");
assert.equal(created, false);
});
test("createShift forwards a valid write upstream then POST-redirect-GETs", async () => {
let got: ShiftInput | undefined;
const r = await createShift(fakeUpstream({ create: async (i) => { got = i; } }))(fakeCtx({ body: "title=Night&assignee=Casey&start=22%3A00&end=06%3A00" }));
assert.deepEqual(got, { assignee: "Casey", end: "06:00", start: "22:00", title: "Night" });
assert.deepEqual(r, { redirect: SHIFTS_PATH });
});
test("createShift surfaces an upstream failure as a recoverable 502 form, keeping the input", async () => {
const r = asView(await createShift(fakeUpstream({ create: async () => { throw new UpstreamError("boom", 500); } }))(fakeCtx({ body: "title=Night&assignee=Casey" })));
assert.equal(r.status, 502);
assert.match(String(r.data["formError"]), /unavailable/i);
const fields = (r.data["form"] as { fields: { name: string; value: string }[] }).fields;
assert.equal(fields.find((f) => f.name === "title")?.value, "Night"); // input preserved for retry
});
test("buildFormModel marks title/assignee required and attaches field errors", () => {
const model = buildFormModel({ chrome: CHROME, errors: { title: "needed" }, values: { title: "x" } });
const fields = model.form.fields as { error?: string; name: string; required?: boolean; value: string }[];
const title = fields.find((f) => f.name === "title")!;
assert.equal(title.required, true);
assert.equal(title.error, "needed");
assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
});

View File

@@ -0,0 +1,192 @@
// Reference plugin (todo §7) — Scheduling/Shifts handlers + the upstream client. Shows the blessed
// shape: a thin handler parses ctx, calls an upstream REST service, and returns a RouteResult the
// host renders. The plugin holds no state of its own (README "Stateless") — data lives upstream.
//
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
// pure functions against a mock upstream with no network (docs/plugin-contract.md → dev/test story).
import { readFormBody } from "../../src/body.ts";
import type { PageChrome } from "../../src/chrome.ts";
import { CSRF_FIELD } from "../../src/csrf.ts";
import { can, GuardError } from "../../src/guards.ts";
import { parseListQuery } from "../../src/list-query.ts";
import type { RouteHandler } from "../../src/plugin.ts";
export const SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // permission token gating the list + nav
export const WRITE = "scheduling:write"; // permission token gating create
export interface Shift {
id: string;
assignee: string;
end: string;
start: string;
title: string;
}
export interface ShiftInput {
assignee: string;
end: string;
start: string;
title: string;
}
// Thrown when the upstream errors; the handler degrades to a recoverable page, never a host 500.
export class UpstreamError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "UpstreamError";
this.status = status;
}
}
export interface ShiftsUpstream {
create(input: ShiftInput): Promise<void>;
list(): Promise<Shift[]>;
}
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch` is
// injectable so handlers test without a network; the base URL comes from the plugin's own env.
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = fetch): ShiftsUpstream {
const base = baseUrl.replace(/\/+$/, "");
return {
async create(input) {
const res = await fetchImpl(`${base}/shifts`, {
body: JSON.stringify(input),
headers: { "content-type": "application/json" },
method: "POST",
});
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
},
async list() {
const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } });
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
const data: unknown = await res.json();
return Array.isArray(data) ? data.map(toShift) : [];
},
};
}
const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v));
function toShift(raw: unknown): Shift {
const r = (raw ?? {}) as Record<string, unknown>;
return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
}
// ---- view models (pure; the EJS views read these) -----------------------------------
export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[] }) {
return {
breadcrumbs: [{ href: SHIFTS_PATH, label: "Scheduling" }, { label: "Shifts" }],
canWrite: opts.canWrite,
chrome: opts.chrome,
...(opts.error ? { error: opts.error } : {}),
filterBar: {
applyLabel: "Search",
clearHref: SHIFTS_PATH,
label: "Filter shifts",
pills: opts.q ? [{ label: "Search", remove: SHIFTS_PATH, value: opts.q }] : [],
rows: [[
{ label: "Search shifts", name: "q", placeholder: "Search title or assignee…", type: "search", value: opts.q },
{ type: "spacer" },
]],
},
newHref: `${SHIFTS_PATH}/new`,
table: {
caption: "Shifts",
columns: [{ label: "Shift" }, { label: "Assignee" }, { label: "Start" }, { label: "End" }],
rows: opts.shifts.map((s) => ({
cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end],
name: s.title,
})),
},
title: "Shifts",
};
}
export function buildFormModel(opts: { chrome: PageChrome; errors?: Record<string, string>; formError?: string; values?: Partial<ShiftInput> }) {
const v = opts.values ?? {};
const e = opts.errors ?? {};
const field = (cfg: { icon?: string; id: string; label: string; type?: string; value: string }) => ({
...cfg, name: cfg.id, ...(e[cfg.id] ? { error: e[cfg.id] } : {}), ...(cfg.id === "title" || cfg.id === "assignee" ? { required: true } : {}),
});
return {
breadcrumbs: [{ href: SHIFTS_PATH, label: "Shifts" }, { label: "New shift" }],
chrome: opts.chrome,
...(opts.formError ? { formError: opts.formError } : {}),
form: {
action: SHIFTS_PATH,
cancelHref: SHIFTS_PATH,
csrfToken: opts.chrome.csrfToken,
fields: [
field({ icon: "i-cal", id: "title", label: "Shift title", value: v.title ?? "" }),
field({ icon: "i-user", id: "assignee", label: "Assignee", value: v.assignee ?? "" }),
field({ id: "start", label: "Start", type: "datetime-local", value: v.start ?? "" }),
field({ id: "end", label: "End", type: "datetime-local", value: v.end ?? "" }),
],
submitLabel: "Create shift",
},
title: "New shift",
};
}
// ---- input + validation -------------------------------------------------------------
export function readInput(form: URLSearchParams): ShiftInput {
return {
assignee: (form.get("assignee") ?? "").trim(),
end: (form.get("end") ?? "").trim(),
start: (form.get("start") ?? "").trim(),
title: (form.get("title") ?? "").trim(),
};
}
// Required-field validation → { field: message } or null. Kept deliberately small; the upstream
// owns the real domain rules (overlap, capacity, …) and rejects with a 4xx the handler surfaces.
export function validate(input: ShiftInput): Record<string, string> | null {
const errors: Record<string, string> = {};
if (!input.title) errors["title"] = "A shift needs a title.";
if (!input.assignee) errors["assignee"] = "Assign the shift to someone.";
return Object.keys(errors).length ? errors : null;
}
// ---- handlers (factories bound to the upstream) -------------------------------------
export function listShifts(upstream: ShiftsUpstream): RouteHandler {
return async (ctx) => {
const q = parseListQuery(ctx.url).q;
let shifts: Shift[] = [];
let error: string | undefined;
try {
shifts = await upstream.list();
} catch {
error = "Couldn't reach the scheduling service — try again shortly.";
}
const needle = q.toLowerCase();
const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts;
return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows }), view: "shifts" };
};
}
export function newShiftForm(): RouteHandler {
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" });
}
export function createShift(upstream: ShiftsUpstream): RouteHandler {
return async (ctx) => {
const form = await readFormBody(ctx.req);
// A write is a first-party form, so guard it with the host's double-submit token (ctx.verifyCsrf).
if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token");
const input = readInput(form);
const errors = validate(input);
if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, values: input }), status: 400, view: "shift-new" };
try {
await upstream.create(input);
} catch {
return { data: buildFormModel({ chrome: ctx.chrome, formError: "Couldn't save the shift — the scheduling service is unavailable.", values: input }), status: 502, view: "shift-new" };
}
return { redirect: SHIFTS_PATH }; // POST-redirect-GET
};
}

View File

@@ -0,0 +1,22 @@
<%#
A plugin's own partial (resolved before the core ones). The new-shift form body, reusing the core
`partials/field` + `partials/alert`. Config: form { action, csrfToken, submitLabel, cancelHref,
fields: field.ejs config[] }, formError?
%><%
const form = locals.form;
-%>
<div class="form-page">
<% if (locals.formError) { -%>
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= form.action %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<% form.fields.forEach((field) => { -%>
<%- include("partials/field", field) %>
<% }) -%>
<div class="form-actions">
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
</div>

View File

@@ -0,0 +1,19 @@
<%#
Scheduling · New shift form (reference plugin). The form POSTs to /scheduling/shifts; the handler
CSRF-verifies and forwards the write upstream. Body comes from this plugin's OWN partial
(partials/shift-form — resolved plugin-first), which reuses the core field partial.
Data: chrome, title, breadcrumbs, form, formError?
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/shift-form", { form, formError: locals.formError });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
nav: navHtml,
theme: chrome.theme,
title,
user: chrome.user,
}) %>

View File

@@ -0,0 +1,26 @@
<%#
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
service; this view renders them with the core building blocks inside the native app shell
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
Data: chrome, title, breadcrumbs, filterBar, table, canWrite, newHref, error?
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const filtersHtml = include("partials/filter-bar", filterBar);
const tableHtml = include("partials/data-table", table);
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
const actions = canWrite
? '<a class="btn btn-primary" href="' + newHref + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>New shift</a>'
: "";
-%>
<%- include("partials/shell", {
actions,
body: alertHtml + filtersHtml + tableHtml,
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
nav: navHtml,
theme: chrome.theme,
title,
user: chrome.user,
}) %>