§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

View File

@@ -9,7 +9,7 @@ import { type AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts";
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts";
import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.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 { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.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).
const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
// base context (no route params yet); reused for onRequest. Chrome is built lazily — only
// plugin routes (and an onRequest short-circuit) read ctx.chrome, so the hot path stays free.
// Chrome (brand/global-nav/user/theme/csrf) is built lazily and at most once per request —
// 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, {
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.
@@ -157,8 +162,7 @@ export function createApp(options: AppOptions = {}): Server {
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const chrome = buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user });
const routeCtx = buildContext(req, res, { chrome, params: match.params, user, verifyCsrf });
const routeCtx = buildContext(req, res, { chrome: chrome(), params: match.params, user, verifyCsrf });
if (!isAuthorized(match.route, routeCtx.roles)) {
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;

View File

@@ -5,7 +5,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
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) =>
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 () => {
const id = randomUUID();
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);
// 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
// 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.
import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { discoverPlugins } from "./discovery.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
// --- 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}` };
}
// 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 -----------------------------------------------------------------
export interface JwksFsHooks {
@@ -124,8 +135,10 @@ async function main() {
const env = process.env;
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.
const roles = (env["ADMIN_ROLES"] ?? "admin,scheduling:read,scheduling:write").split(",").map((r) => r.trim()).filter(Boolean);
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
// 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 password = env["ADMIN_PASSWORD"] ?? "admin";
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
});
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 () => {
const escaped = await render({ title: "<x>", body: "<p>raw</p>" });
assert.match(escaped, /<title>&lt;x&gt;<\/title>/); // user text is escaped