§7 review checkpoint (todo §7); ran the architecture + product reviewers on the whole project and addressed findings, no Critical from either. Made permissions honest + decoupled the host from the plugin: new pure seedRoles + bootstrap discoverPlugins() seeds the demo admin admin(/ADMIN_ROLES) ∪ every discovered plugin's declared tokens, dropped the hardcoded scheduling:* from compose ADMIN_ROLES (clean-clone unchanged); docs now state a route/nav permission is a coarse role granted as Keto Role:<token>#members. Added src/plugin-api.ts — the stable author barrel the reference plugin now imports from instead of deep src/* (the contract boundary in code). Made per-plugin CSS usable: shell styles slot + plugins/scheduling/public/scheduling.css linked from the views. Reference now demonstrates hooks.onBoot validating SCHEDULING_UPSTREAM fail-loud (assertHttpUrl). Build ctx.chrome at most once per request (memoized). Doc honesty: fixed the false visual.spec coverage comment, softened the "every plugin ships a Playwright test" claim (authed flow = §8), added an Upstream contract block to the plugin README. Added LICENSE (MIT). Stability-reviewer APPROVE, no Critical/High; addressed both Low nits. typecheck + 301 units green. Deferred: internal route-table (M1)→§9, safeUrl()→§9, data-table empty-state + success-flash→§8/polish, apiVersion-literal enforcement (prose), permission→requireRole rename (future minor).

This commit is contained in:
2026-06-19 15:31:53 +02:00
parent 45d9b2ede9
commit 4e97fb619e
20 changed files with 214 additions and 50 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Larv IT AB
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -117,8 +117,9 @@ docker compose up # http://localhost:3000, live reload via `node --wa
merging `compose.override.yml`, which mounts the source and restarts the server on merging `compose.override.yml`, which mounts the source and restarts the server on
change. A one-shot `bootstrap` service then seeds first-boot state with **zero manual change. A one-shot `bootstrap` service then seeds first-boot state with **zero manual
prep** — it generates the JWT signing key if absent, creates a demo admin prep** — it generates the JWT signing key if absent, creates a demo admin
(`admin@plainpages.local` / `admin`) in Kratos, and grants it the `admin` role in Keto (`admin@plainpages.local` / `admin`) in Kratos, and grants it the `admin` role plus every
so permission checks resolve out of the box; it is idempotent, so every `up` re-runs it discovered plugin's declared permission tokens in Keto, so permission checks (and any dropped-in
plugin) resolve out of the box; it is idempotent, so every `up` re-runs it
safely. It finishes by printing a banner with the login URL and seeded credentials. safely. It finishes by printing a banner with the login URL and seeded credentials.
**Change the demo admin before production.** The web app waits for Kratos + Keto **Change the demo admin before production.** The web app waits for Kratos + Keto
to be healthy *and* the bootstrap to finish before starting (each Ory service has a to be healthy *and* the bootstrap to finish before starting (each Ory service has a
@@ -282,15 +283,14 @@ The manifest is **TypeScript** — typed, commented, no separate schema to keep
sync. The `id` and mount path are **derived from the folder name**, not declared: sync. The `id` and mount path are **derived from the folder name**, not declared:
```ts ```ts
import { definePlugin } from "../../src/plugin.ts"; import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs)
import { listShifts } from "./shifts.ts"; import { listShifts } from "./shifts.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see docs) apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see docs)
// Nav fragment, composed into the global menu. Permission-gated via Keto: // Nav fragment, composed into the global menu. Permission-gated: items the current user can't
// items the current user can't access are hidden. Arbitrary depth. // access are hidden. Arbitrary depth. `icon` is a Lucide icon by its sprite id (src/icons.ts).
// `icon` is a Lucide icon by its sprite id (src/icons.ts).
nav: [ nav: [
{ {
label: "Scheduling", icon: "i-cal", label: "Scheduling", icon: "i-cal",
@@ -300,8 +300,8 @@ export default definePlugin({
}, },
], ],
// Route handlers, mounted under the plugin's path (/scheduling). `permission` // Route handlers, mounted under the plugin's path (/scheduling). `permission` is a coarse role
// (a Keto check) is enforced before the handler runs. // (a JWT-claim check) enforced before the handler runs.
routes: [ routes: [
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts }, { method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
], ],
@@ -585,6 +585,7 @@ src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, p
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath() src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
src/plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers)
src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2) src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2)
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2) src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2) src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)

View File

@@ -122,8 +122,9 @@ services:
environment: environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# Roles granted to the demo admin: `admin` + the reference plugin's tokens (so it works out of the box). # Base roles for the demo admin; bootstrap also grants every discovered plugin's declared
ADMIN_ROLES: ${ADMIN_ROLES:-admin,scheduling:read,scheduling:write} # permission tokens (so the reference plugin — and any drop-in — works out of the box).
ADMIN_ROLES: ${ADMIN_ROLES:-admin}
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467 KETO_WRITE_URL: http://keto:4467

View File

@@ -55,8 +55,13 @@ Nothing else references it; the operator stays in control through the central me
## The manifest ## 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.
```ts ```ts
import { definePlugin } from "../../src/plugin.ts"; import { definePlugin } from "../../src/plugin-api.ts";
import { listShifts, createShift } from "./shifts.ts"; import { listShifts, createShift } from "./shifts.ts";
export default definePlugin({ export default definePlugin({
@@ -69,7 +74,8 @@ export default definePlugin({
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
}], }],
// Permission tokens this plugin introduces (for docs + Keto seeding). Optional. // 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: [ permissions: [
{ token: "scheduling:read", description: "View shifts" }, { token: "scheduling:read", description: "View shifts" },
{ token: "scheduling:write", description: "Create and edit shifts" }, { token: "scheduling:write", description: "Create and edit shifts" },
@@ -93,7 +99,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
| --- | --- | --- | | --- | --- | --- |
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). | | `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. | | `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. | | `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
@@ -122,8 +128,7 @@ type RouteResult =
```ts ```ts
// shifts.ts // shifts.ts
import type { RequestContext } from "../../src/context.ts"; import { parseListQuery, type RequestContext } from "../../src/plugin-api.ts";
import { parseListQuery } from "../../src/list-query.ts";
export async function listShifts(ctx: RequestContext) { export async function listShifts(ctx: RequestContext) {
const q = parseListQuery(ctx.url); const q = parseListQuery(ctx.url);
@@ -135,8 +140,10 @@ export async function listShifts(ctx: RequestContext) {
- **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names - **`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()` 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 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. partials/subfolders to render a full page — exactly as the built-in screens do. To load the
- **Finer authorization than the route `permission`** uses the guards in `src/guards.ts`: 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 `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, 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 {namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
@@ -205,10 +212,18 @@ depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` i
icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are 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. `ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there.
**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 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 `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 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). 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 ## Contract versioning
@@ -293,9 +308,10 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
so a test can mount a single manifest and assert its routes, nav, and gating without the rest so a test can mount a single manifest and assert its routes, nav, and gating without the rest
of the stack. of the stack.
3. **E2E the user-facing flow.** Per AGENTS.md §6, every plugin page/form ships *with* a 3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
Playwright test in `e2e/`, side-effect-free so the suite stays `fullyParallel`. The test runs `e2e/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
against the live `web` service with the plugin mounted. 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 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. ([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.

View File

@@ -124,7 +124,7 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
// The reference plugin (plugins/scheduling) ships discovered in the image. Its nav + routes are // The reference plugin (plugins/scheduling) ships discovered in the image. Its nav + routes are
// permission-gated, so an anonymous visitor never sees or reaches them (the authenticated list/form // permission-gated, so an anonymous visitor never sees or reaches them (the authenticated list/form
// flow is covered by the full-stack suites). Side-effect-free. // flow needs cross-host login infra — deferred to the §8 full E2E, todo line 121). Side-effect-free.
test("the reference plugin is permission-gated: anonymous → 403, hidden from the dashboard nav", async ({ page }) => { test("the reference plugin is permission-gated: anonymous → 403, hidden from the dashboard nav", async ({ page }) => {
const res = await page.goto("/scheduling/shifts"); const res = await page.goto("/scheduling/shifts");
expect(res?.status()).toBe(403); expect(res?.status()).toBe(403);

View File

@@ -20,9 +20,22 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
## Upstream ## Upstream
Set `SCHEDULING_UPSTREAM` to your backend's base URL (it must expose `GET /shifts` and Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
`POST /shifts`). The dev compose points it at a tiny in-memory mock (`examples/shifts-upstream/`) mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
so `docker compose up` shows the plugin working out of the box. A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
### Upstream contract
Your backend must expose two routes; the plugin treats any non-2xx as a recoverable failure
(the list degrades to a "try again" alert, the create re-renders the form keeping the input).
| Route | Request | Success | Response body |
| --- | --- | --- | --- |
| `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) |
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
## Granting access ## Granting access

View File

@@ -2,16 +2,21 @@
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this // 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. // folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md.
import { definePlugin } from "../../src/plugin.ts"; import { definePlugin } from "../../src/plugin-api.ts";
import { createShift, createUpstream, listShifts, newShiftForm, READ, SHIFTS_PATH, WRITE } from "./shifts.ts"; import { assertHttpUrl, 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 // 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). // 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"); const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
const upstream = createUpstream(upstreamUrl);
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
// Merged into the global menu + filtered per user: the "Shifts" leaf shows only for a user holding // 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. // `scheduling:read`, so the whole "Scheduling" header disappears for everyone else.
nav: [{ nav: [{

View File

@@ -0,0 +1,6 @@
/* Scheduling plugin styles — served at /public/scheduling/scheduling.css (per-plugin static),
linked via the shell `styles` slot. The core design system already styles every building block,
so a plugin only adds what's domain-specific, scoped under its own page class (.scheduling-page). */
.scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem);
}

View File

@@ -7,7 +7,7 @@ import type { RequestContext } from "../../src/context.ts";
import { GuardError } from "../../src/guards.ts"; import { GuardError } from "../../src/guards.ts";
import type { RouteResult } from "../../src/plugin.ts"; import type { RouteResult } from "../../src/plugin.ts";
import { import {
buildFormModel, createShift, createUpstream, listShifts, newShiftForm, readInput, assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts"; } from "./shifts.ts";
@@ -33,6 +33,29 @@ const asView = (r: RouteResult | void) => {
return r as { data: Record<string, unknown>; status?: number; view: string }; return r as { data: Record<string, unknown>; status?: number; view: string };
}; };
// ---- upstream config validation (the onBoot hook) ----
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => {
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
});
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => {
const prev = process.env["SCHEDULING_UPSTREAM"];
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
try {
const manifest = (await import("./plugin.ts")).default;
assert.equal(typeof manifest.hooks?.onBoot, "function");
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
} finally {
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
else process.env["SCHEDULING_UPSTREAM"] = prev;
}
});
// ---- upstream client (fetch injected) ---- // ---- upstream client (fetch injected) ----
test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", async () => { test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", async () => {

View File

@@ -5,12 +5,8 @@
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as // 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). // pure functions against a mock upstream with no network (docs/plugin-contract.md → dev/test story).
import { readFormBody } from "../../src/body.ts"; // One import from the host's plugin-api barrel — the stable author surface (see docs/plugin-contract.md).
import type { PageChrome } from "../../src/chrome.ts"; import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler } from "../../src/plugin-api.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 SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // permission token gating the list + nav export const READ = "scheduling:read"; // permission token gating the list + nav
@@ -46,6 +42,18 @@ export interface ShiftsUpstream {
list(): Promise<Shift[]>; list(): Promise<Shift[]>;
} }
// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config
// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern.
export function assertHttpUrl(value: string, name: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`);
}
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch` is // 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. // 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 { export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = fetch): ShiftsUpstream {

View File

@@ -5,7 +5,7 @@
Data: chrome, title, breadcrumbs, form, formError? Data: chrome, title, breadcrumbs, form, formError?
%><% %><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/shift-form", { form, formError: locals.formError }); const body = '<div class="scheduling-page">' + include("partials/shift-form", { form, formError: locals.formError }) + '</div>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
body, body,
@@ -13,6 +13,7 @@
breadcrumbs, breadcrumbs,
csrfToken: chrome.csrfToken, csrfToken: chrome.csrfToken,
nav: navHtml, nav: navHtml,
styles: ["/public/scheduling/scheduling.css"],
theme: chrome.theme, theme: chrome.theme,
title, title,
user: chrome.user, user: chrome.user,

View File

@@ -15,11 +15,12 @@
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
body: alertHtml + filtersHtml + tableHtml, body: '<div class="scheduling-page">' + alertHtml + filtersHtml + tableHtml + '</div>',
brand: chrome.brand, brand: chrome.brand,
breadcrumbs, breadcrumbs,
csrfToken: chrome.csrfToken, csrfToken: chrome.csrfToken,
nav: navHtml, nav: navHtml,
styles: ["/public/scheduling/scheduling.css"],
theme: chrome.theme, theme: chrome.theme,
title, title,
user: chrome.user, user: chrome.user,

View File

@@ -9,7 +9,7 @@ import { type AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts";
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts"; import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts";
import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts"; import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts";
import { readFormBody } from "./body.ts"; import { readFormBody } from "./body.ts";
import { buildPluginChrome } from "./chrome.ts"; import { buildPluginChrome, type PageChrome } from "./chrome.ts";
import { buildContext, type User } from "./context.ts"; import { buildContext, type User } from "./context.ts";
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts"; import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
import { buildDashboardModel } from "./dashboard.ts"; import { buildDashboardModel } from "./dashboard.ts";
@@ -136,11 +136,16 @@ export function createApp(options: AppOptions = {}): Server {
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret). // Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
const verifyCsrf = (submitted: string | null | undefined): boolean => const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted }); verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
// base context (no route params yet); reused for onRequest. Chrome is built lazily — only // Chrome (brand/global-nav/user/theme/csrf) is built lazily and at most once per request —
// plugin routes (and an onRequest short-circuit) read ctx.chrome, so the hot path stays free. // only plugin routes (and an onRequest short-circuit) read it, so the hot path stays free and
// a matched plugin request doesn't re-compose the whole menu for the onRequest + route ctx.
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
// base context (no route params yet); reused for onRequest.
const ctx = buildContext(req, res, { const ctx = buildContext(req, res, {
user, verifyCsrf, user, verifyCsrf,
...(anyRequestHooks ? { chrome: buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }) } : {}), ...(anyRequestHooks ? { chrome: chrome() } : {}),
}); });
// Plugin onRequest hooks run before routing and may short-circuit the request. // Plugin onRequest hooks run before routing and may short-circuit the request.
@@ -157,8 +162,7 @@ export function createApp(options: AppOptions = {}): Server {
// CSRF cookie is set so those forms have a valid double-submit token. // CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname); const match = matchRoute(plugins, method, pathname);
if (match) { if (match) {
const chrome = buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }); const routeCtx = buildContext(req, res, { chrome: chrome(), params: match.params, user, verifyCsrf });
const routeCtx = buildContext(req, res, { chrome, params: match.params, user, verifyCsrf });
if (!isAuthorized(match.route, routeCtx.roles)) { if (!isAuthorized(match.route, routeCtx.roles)) {
sendHtml(res, 403, await render("403", { title: "Forbidden" })); sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return; return;

View File

@@ -5,7 +5,7 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin } from "./bootstrap.ts"; import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts";
const json = (status: number, body?: unknown) => const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), { new Response(body === undefined ? null : JSON.stringify(body), {
@@ -30,6 +30,16 @@ test("roleTuple grants a role to user:<id> in the Role namespace", () => {
}); });
}); });
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => {
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
});
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => { test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
const id = randomUUID(); const id = randomUUID();
const calls: { method: string; url: string; body?: unknown }[] = []; const calls: { method: string; url: string; body?: unknown }[] = [];

View File

@@ -3,10 +3,12 @@
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus // 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus
// the reference plugin's `scheduling:read`/`scheduling:write`, so the shipped example works. // every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by
// the demo admin with no host config edit (the host stays plugin-agnostic).
// Then prints a first-run banner; fails loud on any unexpected upstream error. // Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { discoverPlugins } from "./discovery.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts";
// --- Pure payload builders (the Kratos/Keto request contracts) ----------------------- // --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
@@ -25,6 +27,15 @@ export function roleTuple(identityId: string, role: string) {
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` }; return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
} }
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])];
}
// --- JWKS safety net ----------------------------------------------------------------- // --- JWKS safety net -----------------------------------------------------------------
export interface JwksFsHooks { export interface JwksFsHooks {
@@ -124,8 +135,10 @@ async function main() {
const env = process.env; const env = process.env;
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) console.log("bootstrap: generated a JWKS signing key"); if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) console.log("bootstrap: generated a JWKS signing key");
// Default roles include the reference plugin's tokens so the shipped example works out of the box. // Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
const roles = (env["ADMIN_ROLES"] ?? "admin,scheduling:read,scheduling:write").split(",").map((r) => r.trim()).filter(Boolean); // shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token));
const roles = seedRoles(env["ADMIN_ROLES"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin"; const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({ const result = await seedAdmin({

14
src/plugin-api.test.ts Normal file
View File

@@ -0,0 +1,14 @@
// The plugin author barrel (§7): the stable surface a plugin imports. Guards that the value exports
// stay present — removing one is a breaking contract change. The types resolve via typecheck (the
// reference plugin imports them from here).
import assert from "node:assert/strict";
import test from "node:test";
import * as api from "./plugin-api.ts";
test("plugin-api re-exports the stable author value surface", () => {
for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD"]) {
assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`);
}
assert.equal(typeof api.definePlugin, "function");
assert.equal(api.definePlugin({ apiVersion: "1.0.0" }).apiVersion, "1.0.0"); // identity helper works through the barrel
});

15
src/plugin-api.ts Normal file
View File

@@ -0,0 +1,15 @@
// The plugin author surface (todo §7) — the ONE module a plugin imports. It re-exports exactly the
// stable contract: definePlugin + the manifest/handler types, the RequestContext, the auth guards,
// and the request-body/CSRF/list-query helpers the blessed pattern needs. This barrel *is* the
// contract boundary in code — the host may refactor any other src/* freely as long as it holds, so
// a plugin should import from here, never reach into deeper modules. See docs/plugin-contract.md.
export { definePlugin } from "./plugin.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { RequestContext, User } from "./context.ts";
export type { PageChrome } from "./chrome.ts";
export type { NavNode } from "./nav.ts";
export { can, check, GuardError, requireSession } from "./guards.ts";
export { parseListQuery } from "./list-query.ts";
export { readFormBody } from "./body.ts";
export { CSRF_FIELD } from "./csrf.ts";

View File

@@ -53,6 +53,15 @@ test("app shell renders a configured logo + default theme, falls back to the bra
assert.match(plain, /id="theme-auto"\s+checked/); // theme-switch default assert.match(plain, /id="theme-auto"\s+checked/); // theme-switch default
}); });
test("app shell links extra per-page stylesheets via the styles slot (e.g. a plugin's own CSS)", async () => {
const withCss = await render({ styles: ["/public/scheduling/scheduling.css"] });
assert.match(withCss, /<link rel="stylesheet" href="\/public\/css\/styles\.css" \/>/); // core stylesheet always present
assert.match(withCss, /<link rel="stylesheet" href="\/public\/scheduling\/scheduling\.css" \/>/); // the extra one
const none = await render(); // no styles → only the core stylesheet
assert.equal((none.match(/rel="stylesheet"/g) ?? []).length, 1);
});
test("app shell escapes text but passes slot HTML through, and renders with defaults", async () => { test("app shell escapes text but passes slot HTML through, and renders with defaults", async () => {
const escaped = await render({ title: "<x>", body: "<p>raw</p>" }); const escaped = await render({ title: "<x>", body: "<p>raw</p>" });
assert.match(escaped, /<title>&lt;x&gt;<\/title>/); // user text is escaped assert.match(escaped, /<title>&lt;x&gt;<\/title>/); // user text is escaped

View File

@@ -112,7 +112,7 @@ everything via Docker.
## 7. Example plugin (reference) ## 7. Example plugin (reference)
- [x] Reference plugin (e.g. people directory or scheduling): list page fetching upstream data, a form that forwards writes upstream, permission-gated nav. → `plugins/scheduling/` is the worked example the docs already reference (so contract + reference agree). `shifts.ts` = an injectable-`fetch` upstream REST client (`createUpstream`, stand-in for the customer's backend — the plugin is stateless) + thin handler **factories** bound to it: `listShifts` fetches `/shifts`, filters by `?q`, renders the data-table (upstream down ⇒ a recoverable error page, never a host 500); `newShiftForm` renders the form; `createShift` reads its own body, **CSRF-guards via `ctx.verifyCsrf`** (403 on a bad token), validates, forwards the create upstream, then POST-redirect-GET (a 4xx upstream ⇒ a recoverable 502 form keeping the input). `plugin.ts` = the manifest: `apiVersion` literal, namespaced `scheduling:read`/`scheduling:write` perms, **permission-gated nav** ("Shifts" gated on `read` so the whole "Scheduling" header vanishes for non-holders), routes gated `read`/`write`. Views (`shifts.ejs`, `shift-new.ejs` + the plugin's **own** `partials/shift-form.ejs`) compose the core building blocks (shell/nav-tree/filter-bar/data-table/field/alert via `include()`) around the **native app shell**. **New host capability so a plugin page is native + secure** (`src/chrome.ts` `buildPluginChrome`): `ctx.chrome` = brand/global-nav/user/theme/csrf the view hands to `partials/shell` — the global menu (a Dashboard link + every discovered plugin's nav fragment + the gated admin section), composed + role-filtered + current-marked by request path; `ctx.verifyCsrf(submitted)` = the host's bound double-submit verifier (plugin never sees the secret). Both added to `RequestContext` (defaulted in `buildContext`, anonymous chrome / fail-closed verify), built per plugin route in `app.ts` (CSRF cookie set when fresh so forms carry a token). The dashboard now merges plugin nav fragments too (reachable from `/`; gated ⇒ invisible to anonymous, so the visual E2E is byte-identical). Out of the box: bootstrap now grants the demo admin `scheduling:read`/`scheduling:write` (generalized `seedAdmin` to a roles list, env `ADMIN_ROLES`); the dev compose runs a tiny stdlib mock upstream (`examples/shifts-upstream/`, `SCHEDULING_UPSTREAM`) so `docker compose up` shows it working. Tooling: `plugins/` added to tsconfig + the `npm test` glob (so plugin authors' tests run via `docker compose run web npm test`). Tests-first: `plugins/scheduling/shifts.test.ts` (client w/ mock fetch · validation · list/create handlers incl. CSRF-403, validation-400, PRG, upstream-502 · form model), `src/chrome.test.ts` (brand/nav/role-filter/current/branding), `app.test.ts` (a plugin view renders the chrome + the CSRF round-trip over HTTP), `dashboard.test.ts` (plugin-fragment merge, gated), `bootstrap.test.ts` (multi-role grant). README **Building a plugin** + Layout and `docs/plugin-contract.md` (the `ctx.chrome`/`ctx.verifyCsrf` additions, the upstream pattern, the dev/test pointer) updated. typecheck + **296 units** green; the Ory-free **visual E2E** (real built image) confirms the plugin is discovered at boot, the routes/nav are permission-gated (anonymous → 403, hidden from the dashboard), and the dashboard still renders identically; live full-stack boot-verified — the stack comes up with the plugin + mock upstream, the upstream serves the seeded shifts and is reachable from `web`, and bootstrap grants the admin `admin`/`scheduling:read`/`scheduling:write` in real Keto (all `allowed:true`); torn down. The authenticated browser happy-path (login → rendered list) is deferred to §8's full E2E (line 114 verifies the contract end-to-end) — it needs the cross-host Playwright login infra, not curl. `apiVersion` stays `1.0.0` (the contract is still being assembled in §7, so chrome/verifyCsrf are part of the initial surface — no minor bump, no warn noise). - [x] Reference plugin (e.g. people directory or scheduling): list page fetching upstream data, a form that forwards writes upstream, permission-gated nav. → `plugins/scheduling/` is the worked example the docs already reference (so contract + reference agree). `shifts.ts` = an injectable-`fetch` upstream REST client (`createUpstream`, stand-in for the customer's backend — the plugin is stateless) + thin handler **factories** bound to it: `listShifts` fetches `/shifts`, filters by `?q`, renders the data-table (upstream down ⇒ a recoverable error page, never a host 500); `newShiftForm` renders the form; `createShift` reads its own body, **CSRF-guards via `ctx.verifyCsrf`** (403 on a bad token), validates, forwards the create upstream, then POST-redirect-GET (a 4xx upstream ⇒ a recoverable 502 form keeping the input). `plugin.ts` = the manifest: `apiVersion` literal, namespaced `scheduling:read`/`scheduling:write` perms, **permission-gated nav** ("Shifts" gated on `read` so the whole "Scheduling" header vanishes for non-holders), routes gated `read`/`write`. Views (`shifts.ejs`, `shift-new.ejs` + the plugin's **own** `partials/shift-form.ejs`) compose the core building blocks (shell/nav-tree/filter-bar/data-table/field/alert via `include()`) around the **native app shell**. **New host capability so a plugin page is native + secure** (`src/chrome.ts` `buildPluginChrome`): `ctx.chrome` = brand/global-nav/user/theme/csrf the view hands to `partials/shell` — the global menu (a Dashboard link + every discovered plugin's nav fragment + the gated admin section), composed + role-filtered + current-marked by request path; `ctx.verifyCsrf(submitted)` = the host's bound double-submit verifier (plugin never sees the secret). Both added to `RequestContext` (defaulted in `buildContext`, anonymous chrome / fail-closed verify), built per plugin route in `app.ts` (CSRF cookie set when fresh so forms carry a token). The dashboard now merges plugin nav fragments too (reachable from `/`; gated ⇒ invisible to anonymous, so the visual E2E is byte-identical). Out of the box: bootstrap now grants the demo admin `scheduling:read`/`scheduling:write` (generalized `seedAdmin` to a roles list, env `ADMIN_ROLES`); the dev compose runs a tiny stdlib mock upstream (`examples/shifts-upstream/`, `SCHEDULING_UPSTREAM`) so `docker compose up` shows it working. Tooling: `plugins/` added to tsconfig + the `npm test` glob (so plugin authors' tests run via `docker compose run web npm test`). Tests-first: `plugins/scheduling/shifts.test.ts` (client w/ mock fetch · validation · list/create handlers incl. CSRF-403, validation-400, PRG, upstream-502 · form model), `src/chrome.test.ts` (brand/nav/role-filter/current/branding), `app.test.ts` (a plugin view renders the chrome + the CSRF round-trip over HTTP), `dashboard.test.ts` (plugin-fragment merge, gated), `bootstrap.test.ts` (multi-role grant). README **Building a plugin** + Layout and `docs/plugin-contract.md` (the `ctx.chrome`/`ctx.verifyCsrf` additions, the upstream pattern, the dev/test pointer) updated. typecheck + **296 units** green; the Ory-free **visual E2E** (real built image) confirms the plugin is discovered at boot, the routes/nav are permission-gated (anonymous → 403, hidden from the dashboard), and the dashboard still renders identically; live full-stack boot-verified — the stack comes up with the plugin + mock upstream, the upstream serves the seeded shifts and is reachable from `web`, and bootstrap grants the admin `admin`/`scheduling:read`/`scheduling:write` in real Keto (all `allowed:true`); torn down. The authenticated browser happy-path (login → rendered list) is deferred to §8's full E2E (line 114 verifies the contract end-to-end) — it needs the cross-host Playwright login infra, not curl. `apiVersion` stays `1.0.0` (the contract is still being assembled in §7, so chrome/verifyCsrf are part of the initial surface — no minor bump, no warn noise).
- [x] Verify the full plugin contract end-to-end against the README. → Cross-checked every contract claim in `README.md` (**Building a plugin**, **Where plugins live**, **Layout**) + `docs/plugin-contract.md` + `plugins/scheduling/README.md` against the implementation (`plugin.ts`/`discovery.ts`/`router.ts`/`view-resolver.ts`/`chrome.ts`/`context.ts`/`guards.ts`/`app.ts` `sendResult`) and the shipped reference plugin. **Contract holds end-to-end**: derived id+mount (no `id`/`basePath` in the manifest), `apiVersion` literal + `checkApiVersion` table, permission-gated nav (the "Scheduling" header vanishes without `scheduling:read`) + gated routes, every `RouteResult` shape incl. `view`+`status` and `redirect`-PRG, `ctx.chrome` → native app shell, `ctx.verifyCsrf` double-submit (403 on bad token), `can()`/`GuardError(403)`, the view-resolver rendering `plugins/<id>/views/*` while `include()`-ing **both** core partials (shell/filter-bar/data-table/field/alert/nav-tree — all present in `views/partials/`) and the plugin's own `partials/shift-form`, per-plugin static, upstream-fetch statelessness; all three reference-plugin icons (`i-cal`/`i-plus`/`i-user`) are registered in `src/icons.ts`. **One material doc drift found + fixed:** `docs/plugin-contract.md`'s reserved-id list was stale — it named 8 ids but `RESERVED_PLUGIN_IDS` has 10 (the §5 `admin` + §6 `oauth2` mounts were missing), so an author would wrongly think `plugins/admin/` or `plugins/oauth2/` was allowed when discovery refuses them (test-covered in `discovery.test.ts`). Updated the doc to list all 10 accurately (docs-only, no behaviour change). typecheck + 296 units green. - [x] Verify the full plugin contract end-to-end against the README. → Cross-checked every contract claim in `README.md` (**Building a plugin**, **Where plugins live**, **Layout**) + `docs/plugin-contract.md` + `plugins/scheduling/README.md` against the implementation (`plugin.ts`/`discovery.ts`/`router.ts`/`view-resolver.ts`/`chrome.ts`/`context.ts`/`guards.ts`/`app.ts` `sendResult`) and the shipped reference plugin. **Contract holds end-to-end**: derived id+mount (no `id`/`basePath` in the manifest), `apiVersion` literal + `checkApiVersion` table, permission-gated nav (the "Scheduling" header vanishes without `scheduling:read`) + gated routes, every `RouteResult` shape incl. `view`+`status` and `redirect`-PRG, `ctx.chrome` → native app shell, `ctx.verifyCsrf` double-submit (403 on bad token), `can()`/`GuardError(403)`, the view-resolver rendering `plugins/<id>/views/*` while `include()`-ing **both** core partials (shell/filter-bar/data-table/field/alert/nav-tree — all present in `views/partials/`) and the plugin's own `partials/shift-form`, per-plugin static, upstream-fetch statelessness; all three reference-plugin icons (`i-cal`/`i-plus`/`i-user`) are registered in `src/icons.ts`. **One material doc drift found + fixed:** `docs/plugin-contract.md`'s reserved-id list was stale — it named 8 ids but `RESERVED_PLUGIN_IDS` has 10 (the §5 `admin` + §6 `oauth2` mounts were missing), so an author would wrongly think `plugins/admin/` or `plugins/oauth2/` was allowed when discovery refuses them (test-covered in `discovery.test.ts`). Updated the doc to list all 10 accurately (docs-only, no behaviour change). typecheck + 296 units green.
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §7 reference-plugin surfaces). Architecture: **no Critical**; Product: **no Critical** this checkpoint. **Fixed now (tests-first):** (1) HIGH (arch + product Blocker) — the contract claimed declaring `permissions` "seeds Keto" but nothing wired it, and the host hardcoded the plugin-specific `scheduling:read`/`write` into `ADMIN_ROLES` (`compose.yml` + `bootstrap.ts`) — core knowing about a plugin. Made the claim true *and* removed the coupling: new pure `seedRoles(adminRolesEnv, declaredTokens)` (union+dedup, trims both sides); `bootstrap.main()` now `discoverPlugins()` → seeds the demo admin `admin`(/`ADMIN_ROLES`) every discovered plugin's declared tokens; `compose.yml` `ADMIN_ROLES` default dropped to just `admin` (host names no plugin). Clean-clone behaviour is unchanged (admin still ends up with the scheduling tokens, now derived via discovery, not hardcoded — `readRoles` enumerates the Role namespace so a granted token reaches the JWT). Docs corrected: a route/nav `permission` **is a coarse role** granted as a Keto `Role:<token>#members` tuple (vs the separate fine-grained `Resource` tier). (2) HIGH (arch) — no delineated plugin SDK: the reference plugin imported six deep `src/*` internals, none of them the documented-stable surface. Added `src/plugin-api.ts` — the one barrel a plugin imports (`definePlugin` + manifest/handler/ctx types + guards + body/CSRF/list-query helpers); repointed `plugins/scheduling/{plugin,shifts}.ts` to it, so the contract boundary now lives in code (the host can refactor internals freely behind it). (3) MEDIUM (arch L1 + product) — per-plugin CSS was documented (`/public/<id>/`) but unusable (the shell `<head>` had no slot) and the reference shipped no `public/`. Added an optional `styles` slot to `shell.ejs` (extra stylesheet hrefs, default `[]`, backward-compatible) + `plugins/scheduling/public/scheduling.css` linked from both reference views (`.scheduling-page` scope) — closes the only unexercised contract feature + the anatomy-diagram drift. (4) MEDIUM (arch M4) — the reference demonstrated no hook; added `hooks.onBoot` validating `SCHEDULING_UPSTREAM` fail-loud at boot (`assertHttpUrl` — unparseable or non-http(s) aborts the boot, a typo no longer degrades every request), closing the hooks-coverage gap. (5) MEDIUM (arch M3) — `ctx.chrome` was built twice on a matched plugin request when any onRequest hook exists; now built at most once per request (memoized lazy `chrome()`), hot path still chrome-free. (6) HIGH/MEDIUM (product) — doc honesty: fixed the false `visual.spec.ts` comment ("covered by the full-stack suites" — it isn't), softened the contract's "every plugin ships *with* a Playwright test" to match reality (the reference's authed list/form happy-path is the §8 item), and added an **Upstream contract** block (routes/JSON shape/status-code expectations) to `plugins/scheduling/README.md`. Stability-reviewer run as a local PR: **APPROVE, no Critical/High**; addressed both Low nits (`seedRoles` now trims/dedups declared tokens too; added an `onBoot`-binding seam test on the real manifest). typecheck + **301 units** green (296 → 301). **Deferred (reviewer-scoped, not the §7 checkpoint):** the host **internal route-table** (fold the admin/oauth if-ladder into one `{method,prefix,handler}` table, derive `RESERVED_PLUGIN_IDS`/`allowedMethods` from it — arch M1) → **§9** (now the top structural item; standalone change, not a review-fix); the `safeUrl()` href helper + a reference row that renders an untrusted URL through it (arch L2 / product 🟢) → **§9** (first untrusted-URL flow / redirect-allowlist work); the data-table **empty state** + **success-flash** after writes (product 🟡, recurring §5/§6 deferral, now visible in the exemplar) → **§8/polish**; compile-time `apiVersion`-literal enforcement (arch M2) → accept prose-only; rename route/nav `permission``requireRole` to match the mechanism (arch H1.3) → a future minor `apiVersion` bump.
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. - [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.

View File

@@ -1,7 +1,8 @@
<%# <%#
App shell: sidebar (brand + nav slot + footer) · topbar · content slot. App shell: sidebar (brand + nav slot + footer) · topbar · content slot.
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial), Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
`actions` (topbar buttons), `body` (page content). Text locals: `title`, `brand` `actions` (topbar buttons), `body` (page content); `styles` is an optional array of
extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title`, `brand`
({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the ({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the
theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field). theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field).
Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth. Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth.
@@ -13,6 +14,7 @@
const nav = locals.nav || ""; const nav = locals.nav || "";
const actions = locals.actions || ""; const actions = locals.actions || "";
const body = locals.body || ""; const body = locals.body || "";
const styles = locals.styles || []; // extra per-page stylesheet hrefs (e.g. a plugin's own CSS)
%><!doctype html> %><!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -20,7 +22,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= title %></title> <title><%= title %></title>
<link rel="stylesheet" href="/public/css/styles.css" /> <link rel="stylesheet" href="/public/css/styles.css" />
<link rel="icon" href="/public/favicon.svg" /> <% styles.forEach((href) => { %><link rel="stylesheet" href="<%= href %>" />
<% }) %><link rel="icon" href="/public/favicon.svg" />
</head> </head>
<body> <body>
<a class="skip-link" href="#main-content">Skip to content</a> <a class="skip-link" href="#main-content">Skip to content</a>