Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout

This commit is contained in:
2026-06-24 00:23:55 +02:00
parent 6d316c4888
commit de22f51c12
113 changed files with 282 additions and 265 deletions
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { test, type TestContext } from "node:test";
import { discoverPlugins } from "./discovery.ts";
// Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures
// default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest.
function scaffold(t: TestContext, files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
t.after(() => rmSync(dir, { force: true, recursive: true }));
for (const [rel, content] of Object.entries(files)) {
const full = join(dir, rel);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, content);
}
return dir;
}
const full = (id: string): string =>
`export default { apiVersion: "1.0.0", nav: [{ id: "${id}:root", label: "${id}" }], ` +
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
});
test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => {
const dir = scaffold(t, { "beta/plugin.ts": full("beta"), "alpha/plugin.ts": full("alpha") });
const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta"]); // deterministic order
assert.equal(plugins[0]?.apiVersion, "1.0.0");
assert.equal(plugins[0]?.nav?.[0]?.label, "alpha");
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
});
// Every per-plugin problem and every error-level conflict aborts boot with a message naming it.
const badCases: Array<{ name: string; files: Record<string, string>; match: RegExp }> = [
{ name: "invalid folder name", files: { "Bad_Name/plugin.ts": full("x") }, match: /Bad_Name/ },
{ name: "reserved id shadows a host route", files: { "login/plugin.ts": full("login") }, match: /login.*reserved/s },
{ name: "reserved admin id shadows the admin screens", files: { "admin/plugin.ts": full("admin") }, match: /admin.*reserved/s },
{ name: "reserved oauth2 id shadows the provider routes", files: { "oauth2/plugin.ts": full("oauth2") }, match: /oauth2.*reserved/s },
{ name: "missing plugin.ts", files: { "broken/readme.txt": "x" }, match: /broken.*plugin\.ts/s },
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },
{ name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s },
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s },
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/s },
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
];
for (const c of badCases) {
test(`fails loud: ${c.name}`, async (t) => {
await assert.rejects(discoverPlugins({ dir: scaffold(t, c.files) }), c.match);
});
}
test("a route + nav node may be marked public and load fine", async (t) => {
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1);
assert.equal(plugins[0]?.routes?.[0]?.public, true);
assert.equal(plugins[0]?.nav?.[0]?.public, true);
});
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1);
assert.equal(typeof plugins[0]?.home, "function");
assert.equal(typeof plugins[0]?.dashboard, "function");
});
test("a shared permission token only warns — both plugins still load", async (t) => {
const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm });
const warnings: string[] = [];
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
assert.equal(plugins.length, 2);
assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a permission-conflict warning");
});
+118
View File
@@ -0,0 +1,118 @@
// Plugin discovery: scan plugins/, import each folder's plugin.ts default export,
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
// (older-minor apiVersion, shared permission token) log and load continues. Folder name = id.
import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { checkApiVersion, findConflicts, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
// Default scan root — <repo>/plugins, i.e. the /app/plugins the container mounts (README).
export const PLUGINS_DIR = join(rootDir, "plugins");
export interface DiscoverOptions {
dir?: string;
logger?: Pick<Console, "warn">; // warn-level diagnostics; defaults to console
}
export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Plugin[]> {
const dir = options.dir ?? PLUGINS_DIR;
const logger = options.logger ?? console;
if (!existsSync(dir)) return []; // a clean clone has no plugins/ yet — zero plugins is valid
const errors: string[] = [];
const plugins: Plugin[] = [];
for (const id of pluginFolders(dir)) {
const fail = (msg: string): void => void errors.push(`plugins/${id}: ${msg}`);
if (!isValidPluginId(id)) {
errors.push(`"${id}" is not a valid plugin folder name (lowercase az, digits, dashes)`);
continue;
}
if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; }
const file = join(dir, id, "plugin.ts");
if (!existsSync(file)) { fail("no plugin.ts found"); continue; }
let mod: { default?: unknown };
try {
mod = (await import(pathToFileURL(file).href)) as { default?: unknown };
} catch (err) {
fail(`failed to import plugin.ts — ${messageOf(err)}`);
continue;
}
const manifest = asManifest(mod.default);
if (!manifest) { fail("plugin.ts must default-export a manifest object"); continue; }
const version = checkApiVersion(manifest.apiVersion);
if (version.level === "refuse") { fail(version.message); continue; }
if (version.level === "warn") logger.warn(`[plugins] ${id}: ${version.message}`);
const shape = shapeError(manifest);
if (shape) { fail(shape); continue; }
plugins.push({ ...manifest, id }); // identity is the folder, not the manifest
}
for (const conflict of findConflicts(plugins)) {
if (conflict.level === "error") errors.push(conflict.message);
else logger.warn(`[plugins] ${conflict.message}`);
}
if (errors.length) {
throw new Error(`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
}
return plugins;
}
// Subfolders of plugins/, sorted for deterministic load order + stable conflict messages. Hidden
// entries (.git, .DS_Store, …) and non-directories are skipped — only folders are plugins.
function pluginFolders(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => e.name)
.sort();
}
function asManifest(value: unknown): PluginManifest | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null;
}
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
function shapeError(manifest: PluginManifest): string | null {
for (const field of ["nav", "permissions", "routes"] as const) {
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
}
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
// a non-function fails loud.
for (const slot of ["home", "dashboard"] as const) {
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
}
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous.
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
}
const navContradiction = findPublicNavContradiction(manifest.nav);
if (navContradiction) return navContradiction;
return null;
}
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) {
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
const inChild = findPublicNavContradiction(node?.children);
if (inChild) return inChild;
}
return null;
}
function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { RequestContext } from "../http/context.ts";
import type { Plugin, PluginHooks } from "./plugin.ts";
import { runBootHooks, runRequestHooks, runResponseHooks } from "./hooks.ts";
const ctx = {} as RequestContext; // the hooks only thread ctx through; they never read it
function plugin(id: string, hooks: PluginHooks): Plugin {
return { apiVersion: "1.0.0", hooks, id };
}
test("runBootHooks runs each onBoot in order, skips plugins without one, and a throw aborts", async () => {
const calls: string[] = [];
await runBootHooks([
plugin("a", { onBoot: () => void calls.push("a") }),
plugin("b", {}), // no onBoot → skipped
plugin("c", { onBoot: async () => void calls.push("c") }),
]);
assert.deepEqual(calls, ["a", "c"]);
await assert.rejects(runBootHooks([plugin("x", { onBoot: () => { throw new Error("boom"); } })]), /boom/);
});
test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => {
const calls: string[] = [];
const short = await runRequestHooks([
plugin("a", { onRequest: () => void calls.push("a") }), // returns void → continue
plugin("b", { onRequest: () => { calls.push("b"); return { html: "stop" }; } }),
plugin("c", { onRequest: () => void calls.push("c") }), // never reached
], ctx);
assert.deepEqual(short?.result, { html: "stop" });
assert.equal(short?.plugin.id, "b"); // the owning plugin (so a `view` result resolves correctly)
assert.deepEqual(calls, ["a", "b"]);
// No hook short-circuits → null (proceed with normal routing).
assert.equal(await runRequestHooks([plugin("a", { onRequest: () => {} })], ctx), null);
});
test("runResponseHooks runs every onResponse as an observer with the result; a throw fails", async () => {
const seen: unknown[] = [];
await runResponseHooks([
plugin("a", { onResponse: (_c, r) => void seen.push(r) }),
plugin("b", {}), // no onResponse → skipped
], ctx, { html: "ok" });
assert.deepEqual(seen, [{ html: "ok" }]);
await assert.rejects(runResponseHooks([plugin("x", { onResponse: () => { throw new Error("boom"); } })], ctx, null), /boom/);
});
+29
View File
@@ -0,0 +1,29 @@
// Plugin lifecycle hooks: the host invokes the optional PluginHooks a plugin may declare
// (docs/plugin-contract.md → Hooks). No sandbox — a throwing hook fails loud (boot for onBoot, the
// request for the others). Hooks run in discovery order (plugins sorted by id). app.ts skips these
// entirely when no plugin declares the hook, so the no-hooks hot path stays free.
import type { RequestContext } from "../http/context.ts";
import type { Plugin, RouteResult } from "./plugin.ts";
// After discovery, before the server listens. A throw aborts boot.
export async function runBootHooks(plugins: Plugin[]): Promise<void> {
for (const plugin of plugins) await plugin.hooks?.onBoot?.();
}
// Before route matching. The first hook to return a RouteResult short-circuits the request — its
// result becomes the response and later hooks + the route handler are skipped. Returns that result
// with its owning plugin (so a `view` result resolves against that plugin's views), or null to proceed.
export async function runRequestHooks(plugins: Plugin[], ctx: RequestContext): Promise<{ plugin: Plugin; result: RouteResult } | null> {
for (const plugin of plugins) {
const result = await plugin.hooks?.onRequest?.(ctx);
if (result != null) return { plugin, result };
}
return null;
}
// After a route handler produces its result. Observers only — the return value is ignored, so a
// hook cannot change the response; a throw fails the request.
export async function runResponseHooks(plugins: Plugin[], ctx: RequestContext, result: RouteResult | null): Promise<void> {
for (const plugin of plugins) await plugin.hooks?.onResponse?.(ctx, result);
}
+16
View File
@@ -0,0 +1,16 @@
// The plugin author barrel: 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", "tracedFetch", "Log", "safeUrl"]) {
assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`);
}
assert.equal(typeof api.definePlugin, "function");
assert.equal(typeof api.tracedFetch, "function"); // the request-trace-aware fetch a plugin uses for upstream calls
assert.equal(api.safeUrl("javascript:alert(1)"), "#"); // the URL sanitiser for rendering untrusted hrefs
assert.equal(api.definePlugin({ apiVersion: "1.0.0" }).apiVersion, "1.0.0"); // identity helper works through the barrel
});
+22
View File
@@ -0,0 +1,22 @@
// The plugin author surface — 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 "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
export { parseListQuery } from "../ui/list-query.ts";
export { readFormBody } from "../http/body.ts";
export { CSRF_FIELD } from "../auth/csrf.ts";
// Sanitise an untrusted URL (upstream/user data) before rendering it in an href/src — partials
// escape text but not URL schemes, so a `javascript:`/`data:` URL would be live XSS (see docs).
export { safeUrl } from "../http/safe-url.ts";
// Observability: `ctx.log` (RequestContext) is the request logger; `tracedFetch` is a drop-in
// `fetch` a plugin uses for upstream calls so they join the request's trace (client span + traceparent).
// The `Log` class is exported so a plugin can type/construct one (e.g. `new Log("none")` in a test).
export { Log, tracedFetch } from "../logger.ts";
+134
View File
@@ -0,0 +1,134 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
checkApiVersion,
definePlugin,
findConflicts,
HOST_API_VERSION,
isValidPluginId,
parseSemver,
RESERVED_PLUGIN_IDS,
type Plugin,
type PluginManifest,
} from "./plugin.ts";
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
import { AUTH_FLOWS } from "../auth/flow-view.ts";
// A representative manifest exercising every field — its existence type-checks the contract.
// `apiVersion` is a literal: a plugin pins the version it was built against, so importing
// HOST_API_VERSION would always equal the host and defeat the check. No `id`/`basePath` — the
// host derives both from the plugin's folder name.
const scheduling: PluginManifest = definePlugin({
apiVersion: "1.0.0",
hooks: { onBoot: () => {} },
nav: [{
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
}],
permissions: [{ description: "View shifts", token: "scheduling:read" }],
routes: [
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
{ handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself
],
});
test("definePlugin returns the manifest unchanged — id/mount come from the folder, not the manifest", () => {
const m: PluginManifest = { apiVersion: "1.0.0" };
assert.equal(definePlugin(m), m); // identity, not a copy
assert.equal(scheduling.routes?.length, 3);
});
test("isValidPluginId accepts lowercase/digits/dashes anywhere and rejects everything else", () => {
for (const ok of ["scheduling", "people-directory", "people2", "v2", "people--dir", "-people", "people-", "a-1-b", "1"]) {
assert.ok(isValidPluginId(ok), ok);
}
for (const bad of ["People", "people_dir", "a/b", "a.b", "a b", ""]) {
assert.ok(!isValidPluginId(bad), bad);
}
});
test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => {
assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 });
assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored
for (const bad of ["1", "1.2", "1.2.3.4", "v1.2.3", "^1.2.3", "01.2.3", "1.2.x", "1.2.3 ", "", 3, null, undefined]) {
assert.equal(parseSemver(bad), null, `${String(bad)} is not a semver`);
}
});
test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newer-minor/major-mismatch/malformed refuse", () => {
assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); // "1.0.0" vs "1.0.0"
assert.equal(checkApiVersion("1.0.5", "1.0.0").level, "ok"); // patch never affects compatibility
assert.equal(checkApiVersion("1.0.0", "1.2.0").level, "warn"); // older minor still runs (additive), nudge to update
assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has
assert.equal(checkApiVersion("2.0.0", "1.5.0").level, "refuse"); // incompatible major (newer)
assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older)
for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) {
assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`);
}
});
// A minimal discovered plugin (id = folder name; mount path is the derived `/<id>`), per case.
const p = (over: Partial<Plugin> & Pick<Plugin, "id">): Plugin => ({ apiVersion: "1.0.0", ...over });
test("findConflicts: a clean set has none", () => {
assert.deepEqual(findConflicts([p({ id: "a" }), p({ id: "b" })]), []);
});
test("findConflicts: a duplicate id and a colliding route are loud errors", () => {
const dupId = findConflicts([p({ id: "a" }), p({ id: "a" })]);
assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error"));
// Cross-plugin routes can't collide (unique `/<id>` prefix); two identical routes in one can.
const noop = () => {};
const dupRoute = findConflicts([p({
id: "a",
routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }],
})]);
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
});
test("findConflicts: duplicate nav id is an error, a shared permission token only warns", () => {
const navDup = findConflicts([
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
]);
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
// Sharing a permission across plugins is legitimate (shared role) → warn, not error.
const permDup = findConflicts([
p({ id: "a", permissions: [{ token: "shared:read" }] }),
p({ id: "b", permissions: [{ token: "shared:read" }] }),
]);
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn"));
});
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
const handler = () => ({ html: "x" });
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
const dashDup = findConflicts([p({ id: "a", dashboard: handler }), p({ id: "b", dashboard: handler })]);
assert.ok(dashDup.some((c) => c.kind === "dashboard" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
// One owner of each (even both on one plugin) is fine.
assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []);
});
// Drift guard: RESERVED_PLUGIN_IDS is a hand-maintained mirror of the host's own top-level mounts —
// a folder claiming one would silently shadow a built-in route. Derive the segments from the real
// route constants so adding a new auth flow or admin screen without reserving its id fails here.
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) is NOT reserved", () => {
const seg = (path: string): string => path.split("/")[1] ?? ""; // first segment of "/x/y"
const builtins = new Set<string>([
...Object.keys(AUTH_FLOWS).map(seg), // /login, /recovery, /registration, /settings, /verification
seg(ADMIN_USERS_BASE), seg(ADMIN_GROUPS_BASE), seg(ADMIN_ROLES_BASE), seg(ADMIN_CLIENTS_BASE), // → admin
"auth", // /auth/complete (login completion)
"logout", // POST /logout
"oauth2", // /oauth2/login · /consent · /logout (Hydra provider)
"dashboard", // the gated app home
"public", // static assets
]);
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
// "/" is owned by the `home` manifest field (not a /<id> route), so it cannot be shadowed and is
// deliberately not reserved — a plugin folder named "home" is legal.
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
});
+216
View File
@@ -0,0 +1,216 @@
// The plugin contract — the product's main API surface: the machine-readable types +
// pure rules; `docs/plugin-contract.md` is the prose reference, discovery/router wire it to FS+HTTP.
// Powerful, predictable, fails loud at boot/discovery rather than sandboxing at runtime.
//
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
import type { RequestContext } from "../http/context.ts";
import type { NavNode } from "../ui/nav.ts";
// Host contract version (semver). Bump major on a breaking manifest/handler change, minor on an
// additive one. A plugin pins the version it targets via `apiVersion`; the host applies
// provider/consumer semver semantics in checkApiVersion (refuse/warn on mismatch).
export const HOST_API_VERSION = "1.0.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
// A handler's return value; the host turns it into the HTTP response. Returning void is the
// escape hatch — the handler wrote to `ctx.res` itself (streaming, custom headers, etc.).
export type RouteResult =
| { headers?: Record<string, string>; html: string; status?: number }
| { headers?: Record<string, string>; json: unknown; status?: number } // for opt-in JS enhancement
| { data?: Record<string, unknown>; headers?: Record<string, string>; status?: number; view: string }
| { redirect: string; status?: number };
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
export interface Route {
handler: RouteHandler;
method: HttpMethod;
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
permission?: string; // coarse gate (a role token); checked before the handler runs
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
// — a no-permission route is already open — but stated outright, so "public" is a deliberate
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
public?: boolean;
}
// A permission token this plugin introduces — declared for docs/seeding. Tokens are a shared
// global namespace (so an operator grants them in Keto); namespace as `<id>:<action>`.
export interface PermissionDecl {
description?: string;
token: string;
}
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
export interface PluginHooks {
onBoot?: () => Promise<void> | void; // after discovery, before the server listens
onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void;
}
// The authored manifest — a plugin's `plugin.ts` default-exports this. No `id`/mount path: the
// host derives them from the folder name at discovery (see Plugin).
export interface PluginManifest {
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
// Take over the gated dashboard "/dashboard" — the post-login app home. A handler like any
// route's; the host gates it to a signed-in session (anonymous → /login), then renders its own view
// via ctx.chrome. At most one plugin may declare it (findConflicts → error, never last-write-wins).
dashboard?: RouteHandler;
// Take over the public landing "/" — the ungated front page. A handler like any route's,
// anyone may reach it. At most one plugin may declare it (findConflicts → error).
home?: RouteHandler;
hooks?: PluginHooks;
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
permissions?: PermissionDecl[];
routes?: Route[];
}
// A discovered plugin: the manifest plus the `id` the host read from the folder name. Mounted
// at `/<id>`, with views/static namespaced under the id.
export interface Plugin extends PluginManifest {
id: string;
}
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
//, so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
export function definePlugin(manifest: PluginManifest): PluginManifest {
return manifest;
}
// A plugin id (its folder name) — lowercase az, digits, and dashes, dashes allowed anywhere.
// Rejects uppercase, underscores, dots, slashes, spaces: the id forms the mount path `/<id>`,
// the view/static namespace, and the central-override target, so it must stay URL/path-safe.
const PLUGIN_ID = /^[a-z0-9-]+$/;
export function isValidPluginId(id: string): boolean {
return PLUGIN_ID.test(id);
}
// Ids the host reserves for its own first-party mount segments (the gated /dashboard, the auth flows,
// /auth/complete, /logout, the /admin screens, the /oauth2 provider routes, the /public/ static).
// Plugin routes resolve before these, so a folder named one of them would silently shadow a
// built-in route — discovery refuses it, loud like any conflict. ("/" is owned by the `home` field,
// not a route, so it can't be shadowed and needs no reservation.)
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
"admin", "auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
]);
export interface Semver {
major: number;
minor: number;
patch: number;
}
// The official semver.org 2.0.0 core regex (major.minor.patch, optional prerelease/build) — a
// standardized parse with no dependency. We compare only major/minor for compatibility, so the
// prerelease/build groups are matched (to accept valid input) but otherwise ignored.
const SEMVER =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
// Parse a strict semver string → {major, minor, patch}, or null. Rejects ranges/prefixes
// (`^1.2.3`, `v1`), leading zeros, whitespace and missing parts — fail loud over coerce.
export function parseSemver(version: unknown): Semver | null {
if (typeof version !== "string") return null;
const m = SEMVER.exec(version);
if (!m) return null;
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
}
export interface VersionCheck {
level: "ok" | "refuse" | "warn";
message: string;
}
// Provider/consumer semver check (full table in docs/plugin-contract.md): same major+minor → ok,
// plugin minor < host → warn, else (newer minor, major mismatch, malformed) → refuse. Patch is
// ignored. Discovery maps refuse→throw, warn→log.
export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HOST_API_VERSION): VersionCheck {
const plugin = parseSemver(pluginVersion);
const host = parseSemver(hostVersion);
if (!host) throw new Error(`hostVersion is not a semver: ${JSON.stringify(hostVersion)}`); // invariant, not user input
if (!plugin) {
return { level: "refuse", message: `apiVersion must be a semver string (e.g. "${hostVersion}"); got ${JSON.stringify(pluginVersion)}` };
}
if (plugin.major !== host.major) {
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — incompatible major` };
}
if (plugin.minor > host.minor) {
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
}
if (plugin.minor < host.minor) {
return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — newer features available` };
}
return { level: "ok", message: `apiVersion ${pluginVersion}` };
}
export interface PluginConflict {
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
level: "error" | "warn";
message: string;
plugins: string[]; // unique ids involved
}
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
// is structural — it follows from the id check, so it needs no rule of its own. Shared permission
// tokens are the one intentional overlap, so they warn rather than error.
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
const out: PluginConflict[] = [];
const idCounts = new Map<string, number>();
for (const plugin of plugins) idCounts.set(plugin.id, (idCounts.get(plugin.id) ?? 0) + 1);
for (const [id, n] of idCounts) {
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
}
// The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner
// each — two plugins claiming either is a loud error, not a race.
for (const slot of ["home", "dashboard"] as const) {
const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id);
if (owners.length > 1) out.push({ kind: slot, level: "error", message: `${owners.length} plugins claim "${slot}" (${owners.join(", ")}); only one may own that page`, plugins: uniq(owners) });
}
collect(plugins, (plugin, push) => {
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
}).forEach((owners, key) => {
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
});
collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => {
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) });
});
collect(plugins, (plugin, push) => {
for (const decl of plugin.permissions ?? []) push(decl.token);
}).forEach((owners, token) => {
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${token}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
});
return out;
}
// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count).
function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map<string, string[]> {
const owners = new Map<string, string[]>();
for (const plugin of plugins) emit(plugin, (key) => owners.set(key, [...(owners.get(key) ?? []), plugin.id]));
return owners;
}
function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void): void {
for (const node of nodes ?? []) {
if (node.id != null) push(node.id);
collectNavIds(node.children, push);
}
}
// A route's full path = the plugin's mount path `/<id>` + the route path. The single source of
// truth for both conflict detection (here) and the router, so they can't disagree.
export function fullPath(id: string, path: string): string {
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
}
function uniq(xs: string[]): string[] {
return [...new Set(xs)];
}
+67
View File
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Plugin, Route } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
const noop: Route["handler"] = () => ({ html: "x" });
// Minimal discovered Plugin — only id + routes matter to the router.
function plugin(id: string, routes: Route[]): Plugin {
return { apiVersion: "1.0.0", id, routes };
}
test("matchRoute matches method + full path under /<id>, resolves params, HEAD falls back to GET", () => {
const plugins = [
plugin("scheduling", [
{ handler: noop, method: "GET", path: "/shifts" },
{ handler: noop, method: "GET", path: "/shifts/:id" },
{ handler: noop, method: "POST", path: "/shifts" },
]),
];
assert.equal(matchRoute(plugins, "GET", "/scheduling/shifts")?.route.method, "GET");
assert.deepEqual(matchRoute(plugins, "GET", "/scheduling/shifts/42")?.params, { id: "42" });
assert.equal(matchRoute(plugins, "POST", "/scheduling/shifts")?.route.method, "POST");
// HEAD is answered by the GET route; PUT (no route) and an unknown path miss.
assert.equal(matchRoute(plugins, "HEAD", "/scheduling/shifts")?.route.method, "GET");
assert.equal(matchRoute(plugins, "PUT", "/scheduling/shifts"), null);
assert.equal(matchRoute(plugins, "GET", "/scheduling/missing"), null);
});
test("matchRoute decodes percent-encoded params and rejects malformed encoding", () => {
const plugins = [plugin("users", [{ handler: noop, method: "GET", path: "/:id" }])];
assert.deepEqual(matchRoute(plugins, "GET", "/users/john%40doe")?.params, { id: "john@doe" });
assert.equal(matchRoute(plugins, "GET", "/users/%ZZ"), null);
});
test("matchRoute prefers the most specific (fewest-param) pattern over a param catch-all", () => {
const plugins = [
plugin("users", [
{ handler: noop, method: "GET", path: "/:id" }, // declared first, still loses to the literal
{ handler: noop, method: "GET", path: "/new" },
]),
];
assert.equal(matchRoute(plugins, "GET", "/users/new")?.route.path, "/new");
assert.equal(matchRoute(plugins, "GET", "/users/123")?.route.path, "/:id");
});
test("allowedMethods lists methods at a path (GET implies HEAD); empty when the path is unknown", () => {
const plugins = [
plugin("x", [
{ handler: noop, method: "GET", path: "/a" },
{ handler: noop, method: "POST", path: "/a" },
]),
];
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
});
test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => {
const open: Route = { handler: noop, method: "GET", path: "/" };
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
assert.equal(isAuthorized(open, []), true);
assert.equal(isAuthorized(gated, []), false);
assert.equal(isAuthorized(gated, ["x:read"]), true);
assert.equal(isAuthorized(gated, ["other"]), false);
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
});
+82
View File
@@ -0,0 +1,82 @@
// Router: pure core mapping method + pathname → a discovered plugin route. I/O-free;
// app.ts is the shell (build context, gate, call handler, render RouteResult). A route mounts at
// `/<id>` + its path (fullPath, shared with conflict detection); `:name` segments → path params.
// Specificity: a literal segment beats a `:param` (/users/new wins /users/:id), order-independent.
import { fullPath, type Plugin, type Route } from "./plugin.ts";
export interface RouteMatch {
params: Record<string, string>;
plugin: Plugin;
route: Route;
}
function segments(path: string): string[] {
return path.split("/").filter(Boolean);
}
function paramCount(path: string): number {
return segments(path).filter((s) => s.startsWith(":")).length;
}
// Match a concrete pathname's segments against a route pattern's; return the params or null.
function matchSegments(pattern: string[], path: string[]): Record<string, string> | null {
if (pattern.length !== path.length) return null;
const params: Record<string, string> = {};
for (let i = 0; i < pattern.length; i++) {
const pat = pattern[i] as string;
const seg = path[i] as string;
if (pat.startsWith(":")) {
try {
params[pat.slice(1)] = decodeURIComponent(seg);
} catch {
return null; // malformed %-encoding → no match
}
} else if (pat !== seg) {
return null;
}
}
return params;
}
// Every plugin route whose path pattern matches `pathname`, regardless of method, with params.
function matchPath(plugins: Plugin[], pathname: string): RouteMatch[] {
const path = segments(pathname);
const out: RouteMatch[] = [];
for (const plugin of plugins) {
for (const route of plugin.routes ?? []) {
const params = matchSegments(segments(fullPath(plugin.id, route.path)), path);
if (params) out.push({ params, plugin, route });
}
}
return out;
}
// The single route for `method` + `pathname`, or null. A GET route also answers HEAD. Among
// matches the most specific (fewest `:param` segments) wins; ties keep discovery order (plugins
// sorted by id, routes as declared) — sort is stable.
export function matchRoute(plugins: Plugin[], method: string, pathname: string): RouteMatch | null {
const wanted = method.toUpperCase();
const candidates = matchPath(plugins, pathname).filter(
(m) => m.route.method === wanted || (wanted === "HEAD" && m.route.method === "GET"),
);
candidates.sort((a, b) => paramCount(a.route.path) - paramCount(b.route.path));
return candidates[0] ?? null;
}
// Methods allowed at `pathname` (for a 405 `Allow` header); empty when no route matches the path.
export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
const methods = new Set<string>();
for (const m of matchPath(plugins, pathname)) {
methods.add(m.route.method);
if (m.route.method === "GET") methods.add("HEAD");
}
return [...methods].sort();
}
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
// the user's roles (from the session JWT) must include the token. The same rule composeNav uses
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
export function isAuthorized(route: Route, roles: string[]): boolean {
return route.public === true || route.permission == null || roles.includes(route.permission);
}
+41
View File
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url";
import { renderPluginView, resolveViewPath } from "./view-resolver.ts";
const coreViewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
test("resolveViewPath resolves names/nested subfolders within the views dir, rejects traversal + control chars", () => {
const dir = "/srv/plugins";
assert.equal(resolveViewPath(dir, "demo", "page"), "/srv/plugins/demo/views/page.ejs");
assert.equal(resolveViewPath(dir, "demo", "shifts/edit"), "/srv/plugins/demo/views/shifts/edit.ejs");
assert.equal(resolveViewPath(dir, "demo", "page.ejs"), "/srv/plugins/demo/views/page.ejs"); // extension not doubled
assert.equal(resolveViewPath(dir, "demo", "../../secret"), null); // traversal escapes the dir
assert.equal(resolveViewPath(dir, "demo", "a\x00b"), null); // control char
});
test("renderPluginView: a (nested) view includes a core building-block partial and its own partial", async (t: TestContext) => {
const pluginsDir = mkdtempSync(join(tmpdir(), "pp-views-"));
t.after(() => rmSync(pluginsDir, { force: true, recursive: true }));
const views = join(pluginsDir, "demo", "views");
mkdirSync(join(views, "partials"), { recursive: true });
mkdirSync(join(views, "sub"), { recursive: true });
writeFileSync(join(views, "partials", "local.ejs"), "<span class=local><%= who %></span>");
writeFileSync(
join(views, "sub", "page.ejs"),
`<%- include("partials/theme-switch") %><%- include("partials/local", { who }) %>`,
);
const render = renderPluginView({ cache: false, coreViewsDir, pluginsDir });
const html = await render("demo", "sub/page", { who: "Plug" });
assert.match(html, /role="radiogroup"/); // core partial, resolved from coreViewsDir
assert.match(html, /<span class=local>Plug<\/span>/); // the plugin's own partial, with data
});
test("renderPluginView throws on an out-of-bounds view name", async () => {
const render = renderPluginView({ cache: false, coreViewsDir, pluginsDir: "/srv/plugins" });
await assert.rejects(render("demo", "../../etc/passwd", {}), /invalid view name/);
});
+38
View File
@@ -0,0 +1,38 @@
// Per-plugin view resolver: render plugins/<id>/views/<view>.ejs and let a plugin view
// reuse the core building-block partials. EJS resolves an include() relative to the current file
// first, then against the `views` roots — so passing [plugin views, core views] makes both the
// plugin's own partials/subfolders and every core partial reachable (the plugin root first, so a
// plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult.
import { isAbsolute, join, relative } from "node:path";
import * as ejs from "ejs";
const CONTROL_CHARS = /[\x00-\x1f]/;
// Resolve a view name → absolute .ejs path within plugins/<id>/views, or null if it escapes that
// dir (traversal) or carries a control char. Names may be nested ("shifts/edit"); a missing
// extension defaults to .ejs.
export function resolveViewPath(pluginsDir: string, pluginId: string, view: string): string | null {
if (CONTROL_CHARS.test(view)) return null;
const viewsDir = join(pluginsDir, pluginId, "views");
const file = join(viewsDir, view.endsWith(".ejs") ? view : `${view}.ejs`);
const rel = relative(viewsDir, file);
return rel.startsWith("..") || isAbsolute(rel) ? null : file;
}
export interface PluginViewOptions {
cache: boolean;
coreViewsDir: string; // core views/ root — its partials become include() roots for plugin views
pluginsDir: string;
}
// Bind the dirs/cache once; the returned fn renders a named view for a given plugin id. Rejects on
// an out-of-bounds view name (developer error — fail loud, like the rest of the host).
export function renderPluginView(options: PluginViewOptions) {
return async (pluginId: string, view: string, data: Record<string, unknown>): Promise<string> => {
const file = resolveViewPath(options.pluginsDir, pluginId, view);
if (file === null) throw new Error(`invalid view name "${view}" for plugin "${pluginId}"`);
const views = [join(options.pluginsDir, pluginId, "views"), options.coreViewsDir];
return ejs.renderFile(file, data, { cache: options.cache, views });
};
}