§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:
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
|
||||
import { after, before, test, type TestContext } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createApp, type AppOptions } from "./app.ts";
|
||||
import { readFormBody } from "./body.ts";
|
||||
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
|
||||
@@ -192,6 +193,59 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per
|
||||
assert.equal((await fetch(url + "/demo/nope")).status, 404);
|
||||
});
|
||||
|
||||
test("a plugin view renders the native chrome; its forms are CSRF-guarded via ctx.verifyCsrf (§7)", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||
mkdirSync(join(dir, "panelkit", "views"), { recursive: true });
|
||||
// The view composes the core shell from ctx.chrome — branding, the global nav, the Sign-out form.
|
||||
writeFileSync(join(dir, "panelkit", "views", "panel.ejs"),
|
||||
`<%- include("partials/shell", { brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), title, user: chrome.user }) %>`);
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
|
||||
const plugin: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "panelkit",
|
||||
nav: [{ href: "/panelkit/panel", icon: "i-grid", id: "panelkit", label: "Panel kit" }],
|
||||
routes: [
|
||||
{ handler: (ctx) => ({ data: { chrome: ctx.chrome, title: "Panel" }, view: "panel" }), method: "GET", path: "/panel" },
|
||||
{
|
||||
handler: async (ctx) => {
|
||||
const form = await readFormBody(ctx.req);
|
||||
if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, "bad csrf");
|
||||
return { redirect: "/panelkit/panel" };
|
||||
},
|
||||
method: "POST", path: "/save",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const secret = "test-csrf-secret";
|
||||
const app = createApp({ csrfSecret: secret, plugins: [plugin], pluginsDir: dir });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
// GET renders the shell: branding (DEFAULT_MENU), the (ungated) plugin nav, and a CSRF cookie
|
||||
// whose token is embedded in the Sign-out form (double-submit).
|
||||
const res = await fetch(url + "/panelkit/panel");
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.text();
|
||||
assert.match(body, /class="brand-name">Plainpages/);
|
||||
assert.match(body, /Panel kit/);
|
||||
const cookieTok = /plainpages_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
|
||||
assert.ok(cookieTok, "a plugin route issues the CSRF cookie when fresh");
|
||||
assert.equal(/name="_csrf" value="([^"]+)"/.exec(body)?.[1], cookieTok);
|
||||
|
||||
// POST with no token → 403 (ctx.verifyCsrf fails closed); matching cookie + field → 303.
|
||||
assert.equal((await fetch(url + "/panelkit/save", { method: "POST", redirect: "manual" })).status, 403);
|
||||
const tok = issueCsrfToken(secret);
|
||||
const ok = await fetch(url + "/panelkit/save", {
|
||||
body: `_csrf=${encodeURIComponent(tok)}`,
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${tok}` },
|
||||
method: "POST", redirect: "manual",
|
||||
});
|
||||
assert.equal(ok.status, 303);
|
||||
});
|
||||
|
||||
// JWT middleware (§4): a verified session cookie populates ctx.user/roles, which the gate reads.
|
||||
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
||||
|
||||
21
src/app.ts
21
src/app.ts
@@ -9,6 +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 { buildContext, type User } from "./context.ts";
|
||||
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
|
||||
import { buildDashboardModel } from "./dashboard.ts";
|
||||
@@ -132,7 +133,15 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
|
||||
// one (the form page below Set-Cookies it). Verified on our own state-changing routes (§4).
|
||||
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
||||
const ctx = buildContext(req, res, { user }); // base context (no route params yet); reused for onRequest
|
||||
// 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.
|
||||
const ctx = buildContext(req, res, {
|
||||
user, verifyCsrf,
|
||||
...(anyRequestHooks ? { chrome: buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }) } : {}),
|
||||
});
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
@@ -143,14 +152,18 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin routes (any method): gate on the route's permission, then run the handler.
|
||||
// Plugin routes (any method): gate on the route's permission, then run the handler. The
|
||||
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
|
||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = buildContext(req, res, { params: match.params, user });
|
||||
const chrome = buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user });
|
||||
const routeCtx = buildContext(req, res, { chrome, params: match.params, user, verifyCsrf });
|
||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||
return;
|
||||
}
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
const result = (await match.route.handler(routeCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
|
||||
await sendResult(res, result, (view, data) => renderView(match.plugin.id, view, data));
|
||||
@@ -353,7 +366,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// Roles from the verified JWT (anonymous ⇒ []); branding/override come from config/menu.ts.
|
||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token, user) }));
|
||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token, user, plugins) }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ test("roleTuple grants a role to user:<id> in the Role namespace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("seedAdmin on a fresh stack creates the identity and grants the role", async () => {
|
||||
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 }[] = [];
|
||||
const fetchImpl = (async (url, init) => {
|
||||
@@ -47,13 +47,17 @@ test("seedAdmin on a fresh stack creates the identity and grants the role", asyn
|
||||
ketoWriteUrl: "http://keto:4467",
|
||||
kratosAdminUrl: "http://kratos:4434",
|
||||
password: "admin",
|
||||
role: "admin",
|
||||
roles: ["admin", "scheduling:read"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { created: true, id, role: "admin" });
|
||||
const put = calls.find((c) => c.url.includes("relation-tuples"))!;
|
||||
assert.equal(put.method, "PUT");
|
||||
assert.deepEqual(put.body, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
|
||||
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
|
||||
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
|
||||
assert.equal(puts.length, 2); // one grant per role
|
||||
assert.ok(puts.every((p) => p.method === "PUT"));
|
||||
assert.deepEqual(puts.map((p) => p.body), [
|
||||
{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` },
|
||||
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` },
|
||||
]);
|
||||
});
|
||||
|
||||
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
|
||||
@@ -76,10 +80,10 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
|
||||
ketoWriteUrl: "http://keto:4467",
|
||||
kratosAdminUrl: "http://kratos:4434",
|
||||
password: "admin",
|
||||
role: "admin",
|
||||
roles: ["admin"],
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { created: false, id, role: "admin" });
|
||||
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
|
||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
|
||||
});
|
||||
|
||||
@@ -92,7 +96,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
||||
ketoWriteUrl: "http://keto:4467",
|
||||
kratosAdminUrl: "http://kratos:4434",
|
||||
password: "admin",
|
||||
role: "admin",
|
||||
roles: ["admin"],
|
||||
}),
|
||||
/Kratos/,
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
||||
// 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 the `admin` role in Keto so menu/permission checks resolve out of the box.
|
||||
// 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.
|
||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -50,13 +51,13 @@ export interface SeedOptions {
|
||||
ketoWriteUrl: string;
|
||||
kratosAdminUrl: string;
|
||||
password: string;
|
||||
role: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface SeedResult {
|
||||
created: boolean;
|
||||
id: string;
|
||||
role: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
||||
@@ -80,15 +81,17 @@ export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
||||
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
|
||||
}
|
||||
|
||||
// Grant the role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
||||
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
||||
body: JSON.stringify(roleTuple(id, opts.role)),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
if (!grant.ok) throw new Error(`bootstrap: Keto grant role failed (${grant.status}): ${await grant.text()}`);
|
||||
// Grant each role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
||||
for (const role of opts.roles) {
|
||||
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
||||
body: JSON.stringify(roleTuple(id, role)),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
|
||||
}
|
||||
|
||||
return { created, id, role: opts.role };
|
||||
return { created, id, roles: opts.roles };
|
||||
}
|
||||
|
||||
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
||||
@@ -121,7 +124,8 @@ 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");
|
||||
|
||||
const role = env["ADMIN_ROLE"] ?? "admin";
|
||||
// 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);
|
||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||
const result = await seedAdmin({
|
||||
@@ -129,9 +133,9 @@ async function main() {
|
||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||
password,
|
||||
role,
|
||||
roles,
|
||||
});
|
||||
console.log(`bootstrap: admin ${result.created ? "created" : "already present"} (${result.id}); role "${role}" granted`);
|
||||
console.log(`bootstrap: admin ${result.created ? "created" : "already present"} (${result.id}); roles granted: ${result.roles.join(", ")}`);
|
||||
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||
}
|
||||
|
||||
|
||||
49
src/chrome.test.ts
Normal file
49
src/chrome.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildPluginChrome } from "./chrome.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "./plugin.ts";
|
||||
|
||||
const scheduling: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "scheduling",
|
||||
nav: [{
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||
icon: "i-cal", id: "scheduling", label: "Scheduling",
|
||||
}],
|
||||
};
|
||||
|
||||
const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label);
|
||||
|
||||
test("anonymous: brand from menu, Guest user, gated plugin + admin nav filtered out", () => {
|
||||
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling] });
|
||||
assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name);
|
||||
assert.equal(chrome.user.name, "Guest");
|
||||
assert.deepEqual(labels(chrome.nav), ["Dashboard"]); // Scheduling (gated child) + Admin dropped
|
||||
});
|
||||
|
||||
test("a permission holder sees the plugin nav; current path opens the active leaf", () => {
|
||||
const chrome = buildPluginChrome({
|
||||
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
||||
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
|
||||
});
|
||||
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]);
|
||||
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
|
||||
assert.equal(section.open, true); // ancestor of the current leaf opened
|
||||
assert.equal(section.children!.find((c) => c.label === "Shifts")!.current, true);
|
||||
assert.equal(chrome.user.name, "ada"); // email local part
|
||||
});
|
||||
|
||||
test("an admin sees the gated admin section", () => {
|
||||
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
|
||||
assert.ok(labels(chrome.nav).includes("Admin"));
|
||||
});
|
||||
|
||||
test("branding logo + default theme flow through when set", () => {
|
||||
const menu: MenuConfig = { branding: { logo: "/logo.svg", name: "Acme", theme: "dark" }, override: {} };
|
||||
const chrome = buildPluginChrome({ menu });
|
||||
assert.equal(chrome.brand.logo, "/logo.svg");
|
||||
assert.equal(chrome.brand.name, "Acme");
|
||||
assert.equal(chrome.theme, "dark");
|
||||
});
|
||||
68
src/chrome.ts
Normal file
68
src/chrome.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// Page chrome for plugin pages (todo §7): the brand / global-nav / user / theme / csrf block a
|
||||
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
|
||||
// admin screens render. Pure; the host builds it once per plugin request (it has the menu config,
|
||||
// the discovered plugins, the signed-in user and the request CSRF token) and exposes it on
|
||||
// ctx.chrome. The nav is the global menu — a Dashboard home link, every discovered plugin's nav
|
||||
// fragment, and the gated admin section — run through composeNav (override + per-user role filter),
|
||||
// with the node whose href matches the current path marked `current` (its ancestors opened).
|
||||
|
||||
import { adminSection } from "./admin-nav.ts";
|
||||
import type { User } from "./context.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "./plugin.ts";
|
||||
import { shellUser, type ShellUser } from "./shell-context.ts";
|
||||
|
||||
export interface PageChrome {
|
||||
brand: { logo?: string; name: string; sub?: string };
|
||||
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
||||
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs
|
||||
theme?: string;
|
||||
user: ShellUser;
|
||||
}
|
||||
|
||||
const HOME: NavNode = { href: "/", icon: "i-grid", id: "dashboard", label: "Dashboard" };
|
||||
|
||||
export interface ChromeOptions {
|
||||
csrfToken?: string;
|
||||
currentPath?: string; // request pathname; the matching nav leaf is marked current
|
||||
menu: MenuConfig;
|
||||
plugins?: Plugin[];
|
||||
user?: User | null;
|
||||
}
|
||||
|
||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
const fragments: NavNode[][] = [[HOME]];
|
||||
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
||||
fragments.push([adminSection()]);
|
||||
|
||||
const roles = opts.user?.roles ?? [];
|
||||
const nav = composeNav(fragments, opts.menu.override, roles);
|
||||
if (opts.currentPath) markCurrent(nav, opts.currentPath);
|
||||
|
||||
const b = opts.menu.branding;
|
||||
return {
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
nav,
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
user: shellUser(opts.user),
|
||||
};
|
||||
}
|
||||
|
||||
// Mark the leaf whose href equals `path` as current and open every ancestor header so the active
|
||||
// page is revealed. Mutates the freshly-composed nodes (composeNav returns new objects each call).
|
||||
// Returns whether this subtree contains the current node.
|
||||
function markCurrent(nodes: NavNode[], path: string): boolean {
|
||||
let hit = false;
|
||||
for (const node of nodes) {
|
||||
const here = node.href === path;
|
||||
const inChild = node.children ? markCurrent(node.children, path) : false;
|
||||
if (here) node.current = true;
|
||||
if (here || inChild) {
|
||||
if (node.children) node.open = true;
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { PageChrome } from "./chrome.ts"; // type-only: no runtime import, so no cycle
|
||||
|
||||
// The request context threaded to every route handler (plugin + built-in), built once
|
||||
// per request by `buildContext`: the router supplies matched path `params`, the §4 JWT
|
||||
@@ -13,6 +14,9 @@ export interface User {
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
|
||||
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
|
||||
chrome: PageChrome;
|
||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||
req: IncomingMessage;
|
||||
@@ -20,13 +24,21 @@ export interface RequestContext {
|
||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||||
url: URL;
|
||||
user: User | null;
|
||||
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
|
||||
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
|
||||
verifyCsrf(submitted: string | null | undefined): boolean;
|
||||
}
|
||||
|
||||
export interface BuildContextOptions {
|
||||
chrome?: PageChrome;
|
||||
params?: Record<string, string>;
|
||||
user?: User | null;
|
||||
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
||||
}
|
||||
|
||||
// Anonymous default chrome — used until the host supplies a real one (built-in routes, tests).
|
||||
const ANON_CHROME: PageChrome = { brand: { name: "Plainpages" }, csrfToken: "", nav: [], user: { email: "", initials: "G", name: "Guest" } };
|
||||
|
||||
export function buildContext(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
@@ -35,6 +47,7 @@ export function buildContext(
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const user = options.user ?? null;
|
||||
return {
|
||||
chrome: options.chrome ?? ANON_CHROME,
|
||||
params: options.params ?? {},
|
||||
query: url.searchParams,
|
||||
req,
|
||||
@@ -42,5 +55,6 @@ export function buildContext(
|
||||
roles: user?.roles ?? [],
|
||||
url,
|
||||
user,
|
||||
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,6 +85,20 @@ test("dashboard menu wires in the permission-gated Admin section (only for admin
|
||||
assert.ok(!plain.nav.some((n) => n.children?.some((c) => c.href === "/admin/users")));
|
||||
});
|
||||
|
||||
test("dashboard merges discovered plugin nav fragments, permission-filtered (§7)", () => {
|
||||
const plugin = {
|
||||
apiVersion: "1.0.0", id: "scheduling",
|
||||
nav: [{ children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], icon: "i-cal", id: "scheduling", label: "Scheduling" }],
|
||||
};
|
||||
// A holder of the plugin permission sees its section, reachable from "/".
|
||||
const granted = buildDashboardModel(new URL("http://x/"), ["scheduling:read"], undefined, "", null, [plugin]);
|
||||
assert.ok(granted.nav.some((n) => n.children?.some((c) => c.href === "/scheduling/shifts")));
|
||||
|
||||
// Anonymous: the gated leaf (and so the whole Scheduling header) is filtered out.
|
||||
const anon = buildDashboardModel(new URL("http://x/"), [], undefined, "", null, [plugin]);
|
||||
assert.equal(anon.nav.find((n) => n.label === "Scheduling"), undefined);
|
||||
});
|
||||
|
||||
test("dashboard paginates: page 2 slices the next rows and preserves state in links", () => {
|
||||
const p2 = buildDashboardModel(new URL("http://x/?sort=-name&page=2"));
|
||||
assert.equal(p2.pagination.summary.from, 13); // 30 rows / 12 per page → page 2 starts at 13
|
||||
|
||||
@@ -7,6 +7,7 @@ import { adminSection } from "./admin-nav.ts";
|
||||
import type { User } from "./context.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import { composeNav, type NavNode, type NavOverride } from "./nav.ts";
|
||||
import type { Plugin } from "./plugin.ts";
|
||||
import { parseListQuery } from "./list-query.ts";
|
||||
import { paginate } from "./paginate.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
@@ -80,7 +81,7 @@ function href(state: State, overrides: Partial<State> = {}): string {
|
||||
return qs ? `?${qs}` : "?";
|
||||
}
|
||||
|
||||
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU, csrfToken = "", user: User | null = null) {
|
||||
export function buildDashboardModel(url: URL | URLSearchParams | string, roles: string[] = [], menu: MenuConfig = DEFAULT_MENU, csrfToken = "", user: User | null = null, plugins: Plugin[] = []) {
|
||||
const query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||
const status = query.filters.status?.[0] ?? "all";
|
||||
const team = query.filters.team?.[0] ?? "";
|
||||
@@ -105,7 +106,7 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
|
||||
|
||||
return {
|
||||
filterBar: filterBar(state),
|
||||
nav: nav(roles, menu.override),
|
||||
nav: nav(roles, menu.override, plugins),
|
||||
pagination: pagination(state, page),
|
||||
shell: buildShellContext({
|
||||
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
|
||||
@@ -120,7 +121,11 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
|
||||
|
||||
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
||||
|
||||
function nav(roles: string[], override: NavOverride): NavNode[] {
|
||||
// Sidebar: the demo "Directory" fragment, then each discovered plugin's own nav fragment (so a
|
||||
// plugin is reachable from "/"; gated nodes stay invisible to non-admins), then the gated admin
|
||||
// section. composeNav applies the central override + per-user role filter.
|
||||
function nav(roles: string[], override: NavOverride, plugins: Plugin[]): NavNode[] {
|
||||
const pluginFragments = plugins.filter((p) => p.nav?.length).map((p) => p.nav as NavNode[]);
|
||||
return composeNav([[
|
||||
{ count: PEOPLE.length, current: true, href: "/", icon: "i-users", id: "people", label: "People" },
|
||||
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
|
||||
@@ -129,6 +134,7 @@ function nav(roles: string[], override: NavOverride): NavNode[] {
|
||||
{ href: "#exports", id: "exports", label: "Exports" },
|
||||
], icon: "i-chart", id: "reports", label: "Reports", open: true },
|
||||
{ href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" },
|
||||
], ...pluginFragments, [
|
||||
adminSection(), // built-in Users/Groups/Roles screens; gated → invisible to non-admins
|
||||
]], override, roles);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user