§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:
16
src/app.ts
16
src/app.ts
@@ -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;
|
||||
|
||||
@@ -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 }[] = [];
|
||||
|
||||
@@ -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
14
src/plugin-api.test.ts
Normal 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
15
src/plugin-api.ts
Normal 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";
|
||||
@@ -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><x><\/title>/); // user text is escaped
|
||||
|
||||
Reference in New Issue
Block a user