diff --git a/README.md b/README.md index f759811..29ec6c1 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,10 @@ is covered end-to-end; tests are independent and run **fully in parallel** for s ## Building a plugin _(planned)_ A plugin is a folder under `plugins/`. The host discovers it at boot — no -registration step, no central wiring. +registration step, no central wiring. The full, authoritative API surface — +manifest shape, handler/`RequestContext` contract, versioning, conflict rules, +hooks, and the dev/test story — is **[docs/plugin-contract.md](docs/plugin-contract.md)** +(`src/plugin.ts` holds the types). The sketch below is the shape. ``` plugins/scheduling/ @@ -183,6 +186,7 @@ import { definePlugin } from "../../src/plugin.ts"; import { listShifts } from "./shifts.ts"; export default definePlugin({ + apiVersion: 1, // host contract this plugin targets; mismatch fails loud at boot id: "scheduling", basePath: "/scheduling", @@ -386,11 +390,12 @@ src/icons.ts Used-icon registry + sprite builder from lucide-static (reg src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs -src/plugin.ts definePlugin() + the host's plugin discovery/router (planned) +src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules (discovery/router planned, §2) views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (planned) plugins/ Drop-in plugin folders, auto-discovered (planned) +docs/ Reference docs (plugin-contract.md — the authoritative plugin API) e2e/ Playwright visual + functional E2E (Dockerfile.e2e + compose.e2e.yml run it) html-css-foundation/ HTML design mockups — the source for the building-block partials; reference the stylesheets in public/css/. diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md new file mode 100644 index 0000000..5b6e2f0 --- /dev/null +++ b/docs/plugin-contract.md @@ -0,0 +1,227 @@ +# The Plainpages plugin contract + +The authoritative reference for the plugin API — the product's main surface. A plugin is a +self-contained folder under `plugins/` that the host discovers at boot; there is no +registration step. The contract is **TypeScript** (`src/plugin.ts`), so the types here are the +single source of truth — this document explains them, the guarantees around them, and the rules +the host enforces. + +**Design stance.** The audience is experienced developers. The API optimises for being +**powerful, predictable, and overloadable** — a plugin can take over as much of a page as it +wants. The host **fails loud at boot/discovery** rather than sandboxing at runtime: a malformed +manifest, a version mismatch, or a conflict stops startup with a clear message. Runtime +crash-isolation (one bad plugin can't take the host down) is a *non-goal* — diagnose at deploy +time, not in production. + +> **Status.** This is the contract the §2 host implements. The types and the pure rules +> (`checkApiVersion`, `findConflicts`) exist today in `src/plugin.ts`; discovery, the router, +> the per-plugin view resolver, and static serving are the next §2 items and wire this contract +> to the filesystem and HTTP. Behaviour described as the host's is the target those items meet. + +## Anatomy of a plugin + +``` +plugins/scheduling/ + plugin.ts # default export: the manifest (definePlugin(...)) + shifts.ts # handlers, helpers — plain modules + views/ # EJS templates for this plugin's pages + shifts.ejs + public/ # static assets, served at /public/scheduling/ + scheduling.css +``` + +Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart." +Nothing else references it; the operator stays in control through the central menu override +(`config/menu.ts`, §2). + +## The manifest + +```ts +import { definePlugin, HOST_API_VERSION } from "../../src/plugin.ts"; +import { listShifts, createShift } from "./shifts.ts"; + +export default definePlugin({ + apiVersion: HOST_API_VERSION, // the host contract this plugin targets + basePath: "/scheduling", // unique mount prefix + id: "scheduling", // globally unique; namespaces views/static/tokens + + // Nav fragment, merged into the global menu and permission-filtered per user. + nav: [{ + icon: "i-cal", id: "scheduling:root", label: "Scheduling", + children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], + }], + + // Permission tokens this plugin introduces (for docs + Keto seeding). Optional. + permissions: [ + { token: "scheduling:read", description: "View shifts" }, + { token: "scheduling:write", description: "Create and edit shifts" }, + ], + + // Route handlers, mounted under basePath. `permission` gates before the handler runs. + routes: [ + { method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts }, + { method: "POST", path: "/shifts", permission: "scheduling:write", handler: createShift }, + ], +}); +``` + +`definePlugin()` only types the object and returns it unchanged — a manifest may equally be a +plain typed object. All validation happens at discovery. + +| Field | Required | Notes | +| --- | --- | --- | +| `apiVersion` | yes | Host contract major version the plugin targets — see [Versioning](#contract-versioning). | +| `basePath` | yes | Absolute, no trailing slash (`/scheduling`). Unique; must not prefix-overlap another plugin's. | +| `id` | yes | Globally unique slug. Namespaces `views/`, `/public//`, and (by convention) nav/permission tokens. | +| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Node `id`s must be globally unique. | +| `permissions` | no | Tokens this plugin introduces; declared for documentation and seeding. | +| `routes` | no | See [Routes & handlers](#routes--handlers). | +| `hooks` | no | See [Hooks](#hooks). | + +A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional. + +## Routes & handlers + +A route is `{ method, path, permission?, handler }`. `path` is **relative to `basePath`**; the +host matches `method` + the resolved full path, extracts `:name` segments into +`ctx.params.name`, runs the `permission` gate (a coarse JWT-claim check — see the README), and +only then calls the handler with the [request context](#requestcontext). + +`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`. + +A handler returns a **`RouteResult`** (or a `Promise` of one); the host turns it into the HTTP +response. Returning `void` is the escape hatch — the handler wrote to `ctx.res` itself. + +```ts +type RouteResult = + | { view: string; data?: Record; status?: number; headers?: Record } + | { html: string; status?: number; headers?: Record } + | { json: unknown; status?: number; headers?: Record } // opt-in JS enhancement + | { redirect: string; status?: number }; // 303 unless status set +``` + +```ts +// shifts.ts +import type { RequestContext } from "../../src/context.ts"; +import { parseListQuery } from "../../src/list-query.ts"; + +export async function listShifts(ctx: RequestContext) { + const q = parseListQuery(ctx.url); + const rows = await fetch(`${upstream}/shifts?${ctx.url.searchParams}`).then((r) => r.json()); + return { view: "shifts", data: { rows, q } }; // renders plugins/scheduling/views/shifts.ejs +} +``` + +- **`view`** resolves against the plugin's own `views/` (the per-plugin view resolver, §2). The + template may `include()` the core building-block partials (app shell, nav tree, data table, …) + to render a full page — exactly as the built-in screens do. +- The handler **fetches its own data** from upstream and renders it; plugins hold no state + (see the README's *Stateless* section). The partials only need rows. +- `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`. + +## RequestContext + +Every handler receives one argument, the `RequestContext` (`src/context.ts`), built once per +request: + +```ts +interface RequestContext { + params: Record; // path params from the route match, e.g. /shifts/:id → { id } + query: URLSearchParams; // alias of url.searchParams + req: IncomingMessage; + res: ServerResponse; + roles: string[]; // user?.roles ?? [] — coarse gate without a null-check + url: URL; + user: User | null; // { id, email, roles } from the verified session JWT, or null +} +``` + +**Stability guarantee.** The fields above are the stable contract — present and non-breaking +across a major `apiVersion`. New fields may be **added** within a major version (additive, never +breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine, +but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come +from the §4 JWT middleware and are `null`/`[]` until a session exists. + +## Nav & permissions + +A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/nav.ts`), which +applies the central override and then **filters per user** by the roles in the session JWT — a +node shows iff it declares no `permission` or the user's roles include that token. Use arbitrary +depth, counts, and icons; see `composeNav` for the node shape. + +Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants +`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace +your tokens as `:` to avoid accidental clashes. Declaring them in `permissions` is +optional but recommended (it documents them and lets the bootstrap seed Keto, §3). + +## Contract versioning + +Each manifest declares `apiVersion` — the host contract major version it targets — and the host +exposes the current `HOST_API_VERSION`. At discovery the host runs `checkApiVersion`: + +| Plugin `apiVersion` vs host | Result | Host action | +| --- | --- | --- | +| equal | `ok` | load | +| less than host | `warn` | load, log — review for deprecated behaviour | +| greater than host | `refuse` | **abort boot** — the plugin needs a newer host | +| missing / not a positive integer | `refuse` | **abort boot** — must be declared | + +The version is a single integer bumped only on a **breaking** manifest/handler change; additive +changes don't bump it (hence "older → warn, still load"). There are no semver ranges — pinned, +explicit, in keeping with the project's versioning rules. + +## Conflict rules + +Plugins are independent folders, so the host detects collisions across all discovered manifests +with `findConflicts` and resolves them **loudly — never last-write-wins**. `error` aborts boot; +`warn` logs and continues. + +| Kind | Level | Rule | +| --- | --- | --- | +| `id` | error | Two plugins share an `id`. Ids must be globally unique (they namespace views/static/tokens). | +| `basePath` | error | Two `basePath`s are equal, or one is a path-prefix of the other (`/x` vs `/x/y`) — routes would shadow. | +| `route` | error | Two routes resolve to the same `method` + full path (within or across plugins). | +| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | +| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `:` if unintended. | + +`permission` is the one intentional overlap, so it warns rather than aborts; everything else is +an error an author fixes before the host will start. + +## Hooks + +Optional, for reacting to system actions. A plugin's `hooks` may implement: + +| Hook | When | May | +| --- | --- | --- | +| `onBoot()` | after discovery, before the server listens | warm caches, validate upstream config | +| `onRequest(ctx)` | before route matching | inspect, or **short-circuit** by returning a `RouteResult` | +| `onResponse(ctx, result)` | after the handler | observe/log; cannot change the response | + +Hooks run with no sandbox — a throwing hook fails loud (boot for `onBoot`, the request for the +others). Keep them cheap; `onRequest` is on the hot path. This surface is intentionally small and +may grow additively within the major version. + +## Local dev & test story + +A plugin is a normal folder of TypeScript, so an author tests it the same way the core is tested +— everything in Docker, no host tooling. + +1. **Unit-test handlers as pure functions.** Keep a handler thin: parse `ctx`, fetch upstream, + return a `RouteResult`. Test the data-shaping in isolation (mock `fetch`/upstream) with + `node --test`, exactly like `src/dashboard.test.ts` tests the dashboard model. No host needed. + + ```bash + docker compose run --rm web npm test + ``` + +2. **Run one plugin against the host.** Drop the folder in `plugins/` and `docker compose up`; + the host discovers it. For an isolated harness, the §2 host exposes plugin injection + (`createApp({ plugins: [myPlugin] })`) so a test can mount a single manifest and assert its + routes, nav, and gating without the rest of the stack. + +3. **E2E the user-facing flow.** Per AGENTS.md §6, every plugin page/form ships *with* a + Playwright test in `e2e/`, side-effect-free so the suite stays `fullyParallel`. The test runs + against the live `web` service with the plugin mounted. + +The validation an author hits is the same the host runs: bad `apiVersion` or a conflict +([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved. diff --git a/src/plugin.test.ts b/src/plugin.test.ts new file mode 100644 index 0000000..bf720f9 --- /dev/null +++ b/src/plugin.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + checkApiVersion, + definePlugin, + findConflicts, + HOST_API_VERSION, + type Plugin, +} from "./plugin.ts"; + +// A representative manifest exercising every field — its existence type-checks the contract +// (handler return variants, nav fragment, permission decls, hooks). The README example. +const scheduling: Plugin = definePlugin({ + apiVersion: HOST_API_VERSION, + basePath: "/scheduling", + hooks: { onBoot: () => {} }, + id: "scheduling", + 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 — it only types; validation is at discovery (§2)", () => { + const m: Plugin = { apiVersion: 1, basePath: "/x", id: "x" }; + assert.equal(definePlugin(m), m); // identity, not a copy + assert.equal(scheduling.routes?.length, 3); +}); + +test("checkApiVersion: match ok, older warns, newer/malformed refuses (host refuses or warns, never silent)", () => { + assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); + assert.equal(checkApiVersion(1, 2).level, "warn"); // plugin targets an older host + assert.equal(checkApiVersion(HOST_API_VERSION + 1).level, "refuse"); // needs a newer host + for (const bad of [0, -1, 1.5, "1", undefined, null, Number.NaN]) { + assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`); + } +}); + +// Minimal valid plugin, overridable per case. +const p = (over: Partial & Pick): Plugin => + definePlugin({ apiVersion: HOST_API_VERSION, ...over }); + +test("findConflicts: a clean set has none", () => { + assert.deepEqual(findConflicts([p({ basePath: "/a", id: "a" }), p({ basePath: "/b", id: "b" })]), []); +}); + +test("findConflicts: duplicate id, overlapping basePath, and colliding route are loud errors", () => { + const dupId = findConflicts([p({ basePath: "/a", id: "a" }), p({ basePath: "/b", id: "a" })]); + assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error")); + + const sameBase = findConflicts([p({ basePath: "/x", id: "a" }), p({ basePath: "/x", id: "b" })]); + assert.ok(sameBase.some((c) => c.kind === "basePath" && c.level === "error")); + + // A basePath that is a path-prefix of another also overlaps (routes would shadow). + const prefix = findConflicts([p({ basePath: "/x", id: "a" }), p({ basePath: "/x/y", id: "b" })]); + assert.ok(prefix.some((c) => c.kind === "basePath" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); + + const noop = () => {}; + const dupRoute = findConflicts([p({ + basePath: "/a", 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")); +}); + +test("findConflicts: duplicate nav id is an error, a shared permission token only warns", () => { + const navDup = findConflicts([ + p({ basePath: "/a", id: "a", nav: [{ id: "dup", label: "A" }] }), + p({ basePath: "/b", 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({ basePath: "/a", id: "a", permissions: [{ token: "shared:read" }] }), + p({ basePath: "/b", id: "b", permissions: [{ token: "shared:read" }] }), + ]); + assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn")); +}); diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..3d697e6 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,159 @@ +// The plugin contract (todo §2) — the product's main API surface. This module is the +// authoritative, machine-readable shape; `docs/plugin-contract.md` is the prose reference. +// It only declares types + pure rules; the §2 discovery/router wire them to the filesystem +// and HTTP. Philosophy: a powerful, predictable, overload-friendly API that fails loud at +// boot/discovery rather than sandboxing at runtime. + +import type { RequestContext } from "./context.ts"; +import type { NavNode } from "./nav.ts"; + +// Host contract major version. Bump on a breaking manifest/handler change; a plugin pins the +// version it targets via `apiVersion` and the host refuses/warns on mismatch (checkApiVersion). +export const HOST_API_VERSION = 1; + +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; html: string; status?: number } + | { headers?: Record; json: unknown; status?: number } // for opt-in JS enhancement + | { data?: Record; headers?: Record; status?: number; view: string } + | { redirect: string; status?: number }; + +export type RouteHandler = (ctx: RequestContext) => Promise | RouteResult | void; + +export interface Route { + handler: RouteHandler; + method: HttpMethod; + path: string; // relative to basePath; ":name" segments become ctx.params.name + permission?: string; // coarse gate (a role token); checked before the handler runs +} + +// 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 `:`. +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; // after discovery, before the server listens + onRequest?: (ctx: RequestContext) => Promise | RouteResult | void; // may short-circuit + onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise | void; +} + +export interface Plugin { + apiVersion: number; // host contract version this plugin targets (= HOST_API_VERSION) + basePath: string; // unique mount prefix, e.g. "/scheduling"; must not overlap another plugin's + hooks?: PluginHooks; + id: string; // globally unique; namespaces views, /public//, and nav/permission tokens + nav?: NavNode[]; // fragment merged into the global menu (composeNav); ids must be globally unique + permissions?: PermissionDecl[]; + routes?: Route[]; +} + +// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery +// (§2), so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`. +export function definePlugin(plugin: Plugin): Plugin { + return plugin; +} + +export interface VersionCheck { + level: "ok" | "refuse" | "warn"; + message: string; +} + +// The versioning rule: equal → ok; plugin older than host → warn (load, review); plugin newer +// or not a positive integer → refuse. Discovery maps refuse→throw, warn→log. +export function checkApiVersion(pluginVersion: unknown, hostVersion: number = HOST_API_VERSION): VersionCheck { + if (typeof pluginVersion !== "number" || !Number.isInteger(pluginVersion) || pluginVersion < 1) { + return { level: "refuse", message: `apiVersion must be a positive integer; got ${JSON.stringify(pluginVersion)}` }; + } + if (pluginVersion > hostVersion) { + return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` }; + } + if (pluginVersion < hostVersion) { + return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — review for deprecated behaviour` }; + } + return { level: "ok", message: `apiVersion ${pluginVersion}` }; +} + +export interface PluginConflict { + kind: "basePath" | "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 +// manifests; discovery throws on any "error" and logs every "warn". 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(); + 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] }); + } + + for (let i = 0; i < plugins.length; i++) { + for (let j = i + 1; j < plugins.length; j++) { + const a = plugins[i] as Plugin; + const b = plugins[j] as Plugin; + if (basePathOverlap(a.basePath, b.basePath)) { + out.push({ kind: "basePath", level: "error", message: `basePath "${a.basePath}" (${a.id}) overlaps "${b.basePath}" (${b.id})`, plugins: uniq([a.id, b.id]) }); + } + } + } + + collect(plugins, (plugin, push) => { + for (const route of plugin.routes ?? []) push(`${route.method} ${joinPath(plugin.basePath, 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 ":" 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 { + const owners = new Map(); + 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); + } +} + +const trimSlash = (s: string): string => s.replace(/\/+$/, ""); + +function basePathOverlap(a: string, b: string): boolean { + const x = trimSlash(a); + const y = trimSlash(b); + return x === y || y.startsWith(`${x}/`) || x.startsWith(`${y}/`); +} + +function joinPath(basePath: string, path: string): string { + return `${trimSlash(basePath)}${path.startsWith("/") ? path : `/${path}`}`; +} + +function uniq(xs: string[]): string[] { + return [...new Set(xs)]; +} diff --git a/todo.md b/todo.md index 4af3076..c082f46 100644 --- a/todo.md +++ b/todo.md @@ -45,7 +45,7 @@ everything via Docker. - [x] Add to principles that we should have full E2E coverage in the Playwright tests - make sure they can run in parallel to get up some speed. → Added **Full, parallel E2E** core principle (AGENTS.md §6 + README): every user-facing flow gets a Playwright test shipped with it, tests stay side-effect-free so the suite runs `fullyParallel` (already set; verified 7 tests / 7 workers). Led by example: added E2E coverage for the 404 page (the one user-facing gap). Fixed the documented run command to `--build` (the runner bakes in `e2e/`, so spec edits were silently ignored without it). ## 2. Plugin host -- [ ] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime. +- [x] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime. It is a target that plugins should be able to overload as much as possible. Hooks on actions in the system is not bad either, if it is possible. → `src/plugin.ts` is the typed, machine-readable contract (single source of truth: manifest `Plugin`, `Route`/`RouteResult`/`RouteHandler`, `PermissionDecl`, `PluginHooks`, `definePlugin()`, `HOST_API_VERSION`) plus the pure rules the §2 host enforces — `checkApiVersion` (equal→ok, older→warn, newer/malformed→refuse) and `findConflicts` (id/basePath-overlap/route = error, duplicate nav-id = error, shared permission token = warn; never last-write-wins). `docs/plugin-contract.md` is the prose reference (anatomy, manifest fields, handler/RouteResult, `RequestContext` stability guarantee, nav/permission namespacing, versioning, conflicts, hooks, dev/test story). README links it + example gained `apiVersion`. Tests-first (`plugin.test.ts`); typecheck + 80 units green. Discovery/router/view-resolver/static stay as the next §2 items that wire this to FS+HTTP. - [ ] Discovery: scan `plugins/`, import each `plugin.ts` default export, validate. - [ ] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context. - [ ] Per-plugin view resolver (`plugins//views/*.ejs`).