From f189f889420686a6870918bf841a8268f45c0477 Mon Sep 17 00:00:00 2001 From: lilleman Date: Fri, 19 Jun 2026 14:48:27 +0200 Subject: [PATCH] =?UTF-8?q?=C2=A77=20reference=20plugin=20(todo=20=C2=A77)?= =?UTF-8?q?;=20plugins/scheduling=20is=20the=20worked=20example=20of=20the?= =?UTF-8?q?=20plugin=20contract=20=E2=80=94=20a=20list=20page=20fetching?= =?UTF-8?q?=20upstream=20data,=20a=20CSRF-guarded=20form=20forwarding=20wr?= =?UTF-8?q?ites=20upstream,=20permission-gated=20nav.=20shifts.ts:=20an=20?= =?UTF-8?q?injectable-fetch=20upstream=20REST=20client=20(stateless=20stan?= =?UTF-8?q?d-in=20for=20the=20customer=20backend)=20+=20thin=20handler=20f?= =?UTF-8?q?actories=20(list=20filters=20by=20=3Fq=20+=20degrades=20to=20a?= =?UTF-8?q?=20recoverable=20page=20on=20upstream-down;=20create=20CSRF-gua?= =?UTF-8?q?rds=20via=20ctx.verifyCsrf,=20validates,=20forwards,=20PRG,=205?= =?UTF-8?q?02=20on=20upstream=204xx).=20plugin.ts:=20apiVersion=20literal,?= =?UTF-8?q?=20namespaced=20scheduling:read/write=20perms,=20nav=20gated=20?= =?UTF-8?q?so=20the=20whole=20Scheduling=20header=20vanishes=20for=20non-h?= =?UTF-8?q?olders.=20Views=20compose=20the=20core=20building=20blocks=20ar?= =?UTF-8?q?ound=20the=20native=20app=20shell,=20incl.=20the=20plugin's=20o?= =?UTF-8?q?wn=20partials/shift-form.=20New=20host=20capability=20so=20a=20?= =?UTF-8?q?plugin=20page=20is=20native=20+=20secure=20(src/chrome.ts=20bui?= =?UTF-8?q?ldPluginChrome):=20ctx.chrome=20=3D=20brand/global-nav/user/the?= =?UTF-8?q?me/csrf=20for=20partials/shell=20(global=20menu=20=3D=20Dashboa?= =?UTF-8?q?rd=20+=20every=20plugin=20nav=20fragment=20+=20gated=20admin=20?= =?UTF-8?q?section,=20role-filtered=20+=20current-marked);=20ctx.verifyCsr?= =?UTF-8?q?f=20=3D=20the=20host's=20bound=20double-submit=20verifier=20(se?= =?UTF-8?q?cret=20stays=20in=20the=20host).=20Both=20added=20to=20RequestC?= =?UTF-8?q?ontext=20(defaulted=20in=20buildContext),=20built=20per=20plugi?= =?UTF-8?q?n=20route=20in=20app.ts=20(CSRF=20cookie=20set=20when=20fresh).?= =?UTF-8?q?=20Dashboard=20merges=20plugin=20nav=20fragments=20too=20(gated?= =?UTF-8?q?=20=3D>=20invisible=20to=20anonymous,=20visual=20E2E=20byte-ide?= =?UTF-8?q?ntical).=20Out=20of=20the=20box:=20bootstrap=20grants=20the=20d?= =?UTF-8?q?emo=20admin=20scheduling:read/write=20(seedAdmin=20generalized?= =?UTF-8?q?=20to=20a=20roles=20list,=20env=20ADMIN=5FROLES);=20dev=20compo?= =?UTF-8?q?se=20runs=20a=20tiny=20stdlib=20mock=20upstream=20(examples/shi?= =?UTF-8?q?fts-upstream,=20SCHEDULING=5FUPSTREAM).=20plugins/=20added=20to?= =?UTF-8?q?=20tsconfig=20+=20the=20npm=20test=20glob.=20Tests-first=20acro?= =?UTF-8?q?ss=20shifts/chrome/app/dashboard/bootstrap.=20README=20Building?= =?UTF-8?q?-a-plugin=20+=20Layout=20and=20docs/plugin-contract.md=20(ctx.c?= =?UTF-8?q?hrome/verifyCsrf,=20upstream=20pattern)=20updated.=20typecheck?= =?UTF-8?q?=20+=20296=20units=20+=20the=20Ory-free=20visual=20E2E=20green?= =?UTF-8?q?=20(plugin=20discovered=20at=20boot,=20routes/nav=20gated,=20da?= =?UTF-8?q?shboard=20unchanged);=20live=20full-stack=20boot-verified=20(st?= =?UTF-8?q?ack=20up=20with=20plugin=20+=20mock=20upstream=20serving=20the?= =?UTF-8?q?=20seeded=20shifts,=20bootstrap=20grants=20in=20real=20Keto=20a?= =?UTF-8?q?ll=20allowed:true)=20then=20torn=20down.=20apiVersion=20stays?= =?UTF-8?q?=201.0.0=20(contract=20still=20assembled=20in=20=C2=A77).=20Aut?= =?UTF-8?q?henticated=20browser=20happy-path=20deferred=20to=20=C2=A78=20f?= =?UTF-8?q?ull=20E2E=20(line=20114).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 19 +- compose.override.yml | 11 + compose.yml | 2 + docs/plugin-contract.md | 15 +- e2e/visual.spec.ts | 12 ++ examples/shifts-upstream/server.mjs | 44 ++++ package.json | 2 +- plugins/scheduling/README.md | 31 +++ plugins/scheduling/plugin.ts | 36 ++++ plugins/scheduling/shifts.test.ts | 138 +++++++++++++ plugins/scheduling/shifts.ts | 192 ++++++++++++++++++ .../scheduling/views/partials/shift-form.ejs | 22 ++ plugins/scheduling/views/shift-new.ejs | 19 ++ plugins/scheduling/views/shifts.ejs | 26 +++ src/app.test.ts | 54 +++++ src/app.ts | 21 +- src/bootstrap.test.ts | 22 +- src/bootstrap.ts | 32 +-- src/chrome.test.ts | 49 +++++ src/chrome.ts | 68 +++++++ src/context.ts | 14 ++ src/dashboard.test.ts | 14 ++ src/dashboard.ts | 12 +- todo.md | 2 +- tsconfig.json | 2 +- 25 files changed, 820 insertions(+), 39 deletions(-) create mode 100644 examples/shifts-upstream/server.mjs create mode 100644 plugins/scheduling/README.md create mode 100644 plugins/scheduling/plugin.ts create mode 100644 plugins/scheduling/shifts.test.ts create mode 100644 plugins/scheduling/shifts.ts create mode 100644 plugins/scheduling/views/partials/shift-form.ejs create mode 100644 plugins/scheduling/views/shift-new.ejs create mode 100644 plugins/scheduling/views/shifts.ejs create mode 100644 src/chrome.test.ts create mode 100644 src/chrome.ts diff --git a/README.md b/README.md index 00bc42c..dc386b3 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,10 @@ A plugin is a folder under `plugins/`. The host discovers it at boot — no 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. +(`src/plugin.ts` holds the types). A complete, runnable reference ships in +**[`plugins/scheduling/`](plugins/scheduling/)** — a list page fetching upstream data, +a CSRF-guarded form forwarding writes upstream, and permission-gated nav. Copy it and +adapt. The sketch below is the shape. ``` plugins/scheduling/ # folder name = the plugin id; mounted at /scheduling @@ -306,9 +309,13 @@ export default definePlugin({ ``` The handler (`listShifts`) fetches its data from an upstream service and renders -it — the plugin holds no state of its own (see below). Each plugin is -**self-contained** (its own nav, routes, views, CSS), so installing one is "drop -the folder, restart." An operator stays in control via a central override. +it — the plugin holds no state of its own (see below); the reference points +`SCHEDULING_UPSTREAM` at its backend (the dev compose ships a tiny mock, +`examples/shifts-upstream/`). A `view` result renders against the native app shell +via **`ctx.chrome`** (branding, the global nav, the signed-in user), and a write form +guards itself with **`ctx.verifyCsrf`** + the token in `ctx.chrome.csrfToken`. Each +plugin is **self-contained** (its own nav, routes, views, CSS), so installing one is +"drop the folder, restart." An operator stays in control via a central override. ### Where plugins live (and how to mount them) @@ -572,6 +579,7 @@ src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto src/admin-clients.ts Built-in OAuth2 clients admin screen (§6): list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile) +src/chrome.ts buildPluginChrome(): the brand/global-nav/user/theme/csrf a plugin view renders the native shell from — exposed on ctx.chrome (§7) src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) 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 @@ -585,7 +593,8 @@ views/ Core EJS templates: index (app-shell dashboard), admin/ (Us public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (optional; defaults apply if absent) ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service) -plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in) (planned) +plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships scheduling/ — the §7 reference plugin (list/form over an upstream + permission-gated nav) you copy +examples/ Non-app helpers; shifts-upstream/ is the dev mock backend the reference plugin reads/writes (stand-in for your real service) docs/ Reference docs (plugin-contract.md — the authoritative plugin API) e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent → authorization code), full Ory stack; Dockerfile.e2e + compose.e2e[-auth|-oauth].yml run them html-css-foundation/ HTML design mockups — the source for the building-block diff --git a/compose.override.yml b/compose.override.yml index c05a899..01e3ad4 100644 --- a/compose.override.yml +++ b/compose.override.yml @@ -8,10 +8,21 @@ services: CACHE_TEMPLATES: "false" REQUIRE_SECURE_SECRETS: "false" SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent + SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # reference plugin → the dev mock backend volumes: - .:/app - /app/node_modules + # Dev mock backend for the reference plugin (plugins/scheduling). A stand-in for the customer's + # real scheduling service — stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at + # the real backend instead. Uses the pinned app image so there's nothing extra to build/pull. + shifts-upstream: + image: node:24.16.0-alpine3.24 + command: node /srv/server.mjs + restart: unless-stopped + volumes: + - ./examples/shifts-upstream:/srv:ro + # Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025). # kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env. mailpit: diff --git a/compose.yml b/compose.yml index c24b79a..9a303f1 100644 --- a/compose.yml +++ b/compose.yml @@ -122,6 +122,8 @@ services: environment: ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} + # Roles granted to the demo admin: `admin` + the reference plugin's tokens (so it works out of the box). + ADMIN_ROLES: ${ADMIN_ROLES:-admin,scheduling:read,scheduling:write} APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json KETO_WRITE_URL: http://keto:4467 diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index f4c33a8..8a350e7 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -168,6 +168,7 @@ request: ```ts interface RequestContext { + chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell params: Record; // path params from the route match, e.g. /shifts/:id → { id } query: URLSearchParams; // alias of url.searchParams req: IncomingMessage; @@ -175,9 +176,19 @@ interface RequestContext { 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 + verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie } ``` +**`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, theme, +user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same +sidebar, branding, theme switch and signed-in profile as the built-in screens); `chrome.nav` is the +global menu — your plugin's nav fragment plus the others and the admin section — already composed, +role-filtered, and current-marked for this request. **`ctx.verifyCsrf(submitted)`** guards a +state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own +body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the +secret and sets the cookie; the plugin never touches it. (See the reference: `plugins/scheduling/`.) + **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, @@ -262,7 +273,9 @@ 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. +— everything in Docker, no host tooling. The shipped reference (`plugins/scheduling/`) is the +worked example: thin handlers bound to an injectable upstream client, unit-tested in +`shifts.test.ts` with a mocked `fetch` and a hand-built `ctx` (no host). 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 diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index 593ffe9..2a3e168 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -121,3 +121,15 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to await expect(page.getByRole("heading", { name: "Page not found" })).toBeVisible(); await expect(page.getByRole("link", { name: "Back home" })).toBeVisible(); }); + +// The reference plugin (plugins/scheduling) ships discovered in the image. Its nav + routes are +// permission-gated, so an anonymous visitor never sees or reaches them (the authenticated list/form +// flow is covered by the full-stack suites). Side-effect-free. +test("the reference plugin is permission-gated: anonymous → 403, hidden from the dashboard nav", async ({ page }) => { + const res = await page.goto("/scheduling/shifts"); + expect(res?.status()).toBe(403); + + await page.goto("/"); + await expect(page.locator(".sidebar")).toContainText("People"); // dashboard nav renders + await expect(page.locator(".sidebar")).not.toContainText("Scheduling"); // gated leaf filtered out +}); diff --git a/examples/shifts-upstream/server.mjs b/examples/shifts-upstream/server.mjs new file mode 100644 index 0000000..e8bb1ef --- /dev/null +++ b/examples/shifts-upstream/server.mjs @@ -0,0 +1,44 @@ +// Dev-only mock upstream for the reference plugin (plugins/scheduling) — a stand-in for the +// customer's real backend so `docker compose up` shows the plugin working out of the box. NOT part +// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM +// at your real service in production. +// +// GET /shifts → 200 [ { id, title, assignee, start, end }, … ] +// POST /shifts → 201 { id, … } (body: { title, assignee, start, end }) + +import { randomUUID } from "node:crypto"; +import { createServer } from "node:http"; + +const PORT = Number(process.env.PORT ?? 4000); + +const shifts = [ + { id: randomUUID(), title: "Morning — Front desk", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" }, + { id: randomUUID(), title: "Afternoon — Support", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" }, + { id: randomUUID(), title: "Evening — On-call", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" }, +]; + +const json = (res, status, body) => { + res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(body)); +}; + +const readBody = (req) => + new Promise((resolve) => { + let data = ""; + req.on("data", (c) => (data += c)); + req.on("end", () => { + try { resolve(data ? JSON.parse(data) : {}); } catch { resolve({}); } + }); + }); + +createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://localhost"); + if (url.pathname === "/shifts" && req.method === "GET") return json(res, 200, shifts); + if (url.pathname === "/shifts" && req.method === "POST") { + const b = await readBody(req); + const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") }; + shifts.push(shift); + return json(res, 201, shift); + } + json(res, 404, { error: "not found" }); +}).listen(PORT, () => console.log(`shifts-upstream listening on :${PORT}`)); diff --git a/package.json b/package.json index c58b67b..35fdc6a 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "node --watch src/server.ts", "gen-jwks": "node src/gen-jwks.ts", "typecheck": "tsc --noEmit", - "test": "node --test \"src/**/*.test.ts\"" + "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\"" }, "dependencies": { "ejs": "3.1.10", diff --git a/plugins/scheduling/README.md b/plugins/scheduling/README.md new file mode 100644 index 0000000..fb3d33e --- /dev/null +++ b/plugins/scheduling/README.md @@ -0,0 +1,31 @@ +# Scheduling — the reference plugin + +A worked example of the [plugin contract](../../docs/plugin-contract.md). Copy this folder, rename +it (the folder name becomes the plugin id and mount path), and point it at your own backend. + +What it demonstrates: + +- **A list page that fetches upstream data** — `GET /scheduling/shifts` calls the upstream REST + service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar, + data-table). Search round-trips the URL; zero-JS. +- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form, + `POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream, + then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`, + reusing the core `field` partial. +- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` / + `scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant. + +The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and +`fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`). + +## Upstream + +Set `SCHEDULING_UPSTREAM` to your backend's base URL (it must expose `GET /shifts` and +`POST /shifts`). The dev compose points it at a tiny in-memory mock (`examples/shifts-upstream/`) +so `docker compose up` shows the plugin working out of the box. + +## Granting access + +A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write` +to create). The one-command bootstrap grants both to the demo admin, so the seeded +`admin@plainpages.local` can use it immediately. diff --git a/plugins/scheduling/plugin.ts b/plugins/scheduling/plugin.ts new file mode 100644 index 0000000..283dece --- /dev/null +++ b/plugins/scheduling/plugin.ts @@ -0,0 +1,36 @@ +// Reference plugin (todo §7): a worked example of the contract — a list page that fetches upstream +// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this +// folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md. + +import { definePlugin } from "../../src/plugin.ts"; +import { createShift, createUpstream, listShifts, newShiftForm, READ, SHIFTS_PATH, WRITE } from "./shifts.ts"; + +// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is +// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream). +const upstream = createUpstream(process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000"); + +export default definePlugin({ + apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION + + // Merged into the global menu + filtered per user: the "Shifts" leaf shows only for a user holding + // `scheduling:read`, so the whole "Scheduling" header disappears for everyone else. + nav: [{ + children: [{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ }], + icon: "i-cal", + id: "scheduling", + label: "Scheduling", + }], + + // Tokens this plugin introduces (docs + Keto seeding). Namespaced `:`. + permissions: [ + { description: "View shifts", token: READ }, + { description: "Create and edit shifts", token: WRITE }, + ], + + // Mounted under /scheduling; `permission` gates before the handler runs. + routes: [ + { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ }, + { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, + { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, + ], +}); diff --git a/plugins/scheduling/shifts.test.ts b/plugins/scheduling/shifts.test.ts new file mode 100644 index 0000000..e58eb73 --- /dev/null +++ b/plugins/scheduling/shifts.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { Readable } from "node:stream"; +import test from "node:test"; +import type { PageChrome } from "../../src/chrome.ts"; +import type { RequestContext } from "../../src/context.ts"; +import { GuardError } from "../../src/guards.ts"; +import type { RouteResult } from "../../src/plugin.ts"; +import { + buildFormModel, createShift, createUpstream, listShifts, newShiftForm, readInput, + SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, +} from "./shifts.ts"; + +const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], user: { email: "", initials: "T", name: "Tester" } }; + +function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { + const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); + const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; + return { + chrome: CHROME, params: {}, query: url.searchParams, req, res: {} as ServerResponse, + roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true), + }; +} + +const SHIFTS: Shift[] = [ + { assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" }, + { assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" }, +]; +const fakeUpstream = (over: Partial = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over }); + +const asView = (r: RouteResult | void) => { + assert.ok(r && "view" in r, "expected a view result"); + return r as { data: Record; status?: number; view: string }; +}; + +// ---- upstream client (fetch injected) ---- + +test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", async () => { + let seen = ""; + const http = (async (url, init) => { + seen = String(url); + assert.equal((init?.headers as Record).accept, "application/json"); + return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 }); + }) as typeof fetch; + const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed + assert.equal(seen, "http://up:4000/shifts"); + assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]); +}); + +test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => { + const http = (async () => new Response("nope", { status: 503 })) as typeof fetch; + await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503); +}); + +test("createUpstream.create POSTs the input as JSON", async () => { + let body: unknown, method = ""; + const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch; + const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" }; + await createUpstream("http://up:4000", http).create(input); + assert.equal(method, "POST"); + assert.deepEqual(body, input); +}); + +// ---- input + validation ---- + +test("readInput trims; validate requires title + assignee", () => { + assert.deepEqual(readInput(new URLSearchParams("title=%20Shift%20&assignee=Bo&start=1&end=2")), { assignee: "Bo", end: "2", start: "1", title: "Shift" }); + assert.equal(validate({ assignee: "Bo", end: "", start: "", title: "Shift" }), null); + assert.deepEqual(Object.keys(validate({ assignee: "", end: "", start: "", title: "" }) ?? {}), ["title", "assignee"]); +}); + +// ---- list handler ---- + +test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => { + const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] }))); + assert.equal(r.view, "shifts"); + const table = r.data["table"] as { rows: { name: string }[] }; + assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]); + assert.equal(r.data["canWrite"], true); + assert.equal(r.data["chrome"], CHROME); + + const filtered = asView(await listShifts(fakeUpstream())(fakeCtx({ url: "http://localhost/scheduling/shifts?q=afternoon" }))); + assert.deepEqual((filtered.data["table"] as { rows: { name: string }[] }).rows.map((x) => x.name), ["Afternoon support"]); + assert.equal(filtered.data["canWrite"], false); // no scheduling:write +}); + +test("listShifts degrades to a recoverable error page when the upstream is down (no throw)", async () => { + const r = asView(await listShifts(fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } }))(fakeCtx())); + assert.match(String(r.data["error"]), /scheduling service/i); + assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); +}); + +// ---- create handler ---- + +test("newShiftForm renders the empty form", async () => { + const r = asView(await newShiftForm()(fakeCtx())); + assert.equal(r.view, "shift-new"); + assert.equal((r.data["form"] as { csrfToken: string }).csrfToken, "tok"); +}); + +test("createShift rejects a bad CSRF token with a 403 GuardError", async () => { + await assert.rejects( + async () => { await createShift(fakeUpstream())(fakeCtx({ body: "title=T&assignee=A", verifyCsrf: () => false })); }, + (e: unknown) => e instanceof GuardError && e.status === 403, + ); +}); + +test("createShift re-renders the form (400) on a validation error, never touching the upstream", async () => { + let created = false; + const r = asView(await createShift(fakeUpstream({ create: async () => { created = true; } }))(fakeCtx({ body: "title=&assignee=" }))); + assert.equal(r.status, 400); + assert.equal(r.view, "shift-new"); + assert.equal(created, false); +}); + +test("createShift forwards a valid write upstream then POST-redirect-GETs", async () => { + let got: ShiftInput | undefined; + const r = await createShift(fakeUpstream({ create: async (i) => { got = i; } }))(fakeCtx({ body: "title=Night&assignee=Casey&start=22%3A00&end=06%3A00" })); + assert.deepEqual(got, { assignee: "Casey", end: "06:00", start: "22:00", title: "Night" }); + assert.deepEqual(r, { redirect: SHIFTS_PATH }); +}); + +test("createShift surfaces an upstream failure as a recoverable 502 form, keeping the input", async () => { + const r = asView(await createShift(fakeUpstream({ create: async () => { throw new UpstreamError("boom", 500); } }))(fakeCtx({ body: "title=Night&assignee=Casey" }))); + assert.equal(r.status, 502); + assert.match(String(r.data["formError"]), /unavailable/i); + const fields = (r.data["form"] as { fields: { name: string; value: string }[] }).fields; + assert.equal(fields.find((f) => f.name === "title")?.value, "Night"); // input preserved for retry +}); + +test("buildFormModel marks title/assignee required and attaches field errors", () => { + const model = buildFormModel({ chrome: CHROME, errors: { title: "needed" }, values: { title: "x" } }); + const fields = model.form.fields as { error?: string; name: string; required?: boolean; value: string }[]; + const title = fields.find((f) => f.name === "title")!; + assert.equal(title.required, true); + assert.equal(title.error, "needed"); + assert.equal(fields.find((f) => f.name === "start")!.required, undefined); +}); diff --git a/plugins/scheduling/shifts.ts b/plugins/scheduling/shifts.ts new file mode 100644 index 0000000..170ebc5 --- /dev/null +++ b/plugins/scheduling/shifts.ts @@ -0,0 +1,192 @@ +// Reference plugin (todo §7) — Scheduling/Shifts handlers + the upstream client. Shows the blessed +// shape: a thin handler parses ctx, calls an upstream REST service, and returns a RouteResult the +// host renders. The plugin holds no state of its own (README "Stateless") — data lives upstream. +// +// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as +// pure functions against a mock upstream with no network (docs/plugin-contract.md → dev/test story). + +import { readFormBody } from "../../src/body.ts"; +import type { PageChrome } from "../../src/chrome.ts"; +import { CSRF_FIELD } from "../../src/csrf.ts"; +import { can, GuardError } from "../../src/guards.ts"; +import { parseListQuery } from "../../src/list-query.ts"; +import type { RouteHandler } from "../../src/plugin.ts"; + +export const SHIFTS_PATH = "/scheduling/shifts"; +export const READ = "scheduling:read"; // permission token gating the list + nav +export const WRITE = "scheduling:write"; // permission token gating create + +export interface Shift { + id: string; + assignee: string; + end: string; + start: string; + title: string; +} + +export interface ShiftInput { + assignee: string; + end: string; + start: string; + title: string; +} + +// Thrown when the upstream errors; the handler degrades to a recoverable page, never a host 500. +export class UpstreamError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = "UpstreamError"; + this.status = status; + } +} + +export interface ShiftsUpstream { + create(input: ShiftInput): Promise; + list(): Promise; +} + +// REST client over the upstream service (a stand-in for the customer's real backend). `fetch` is +// injectable so handlers test without a network; the base URL comes from the plugin's own env. +export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = fetch): ShiftsUpstream { + const base = baseUrl.replace(/\/+$/, ""); + return { + async create(input) { + const res = await fetchImpl(`${base}/shifts`, { + body: JSON.stringify(input), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status); + }, + async list() { + const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } }); + if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status); + const data: unknown = await res.json(); + return Array.isArray(data) ? data.map(toShift) : []; + }, + }; +} + +const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? "" : String(v)); + +function toShift(raw: unknown): Shift { + const r = (raw ?? {}) as Record; + return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) }; +} + +// ---- view models (pure; the EJS views read these) ----------------------------------- + +export function buildListModel(opts: { canWrite: boolean; chrome: PageChrome; error?: string; q: string; shifts: Shift[] }) { + return { + breadcrumbs: [{ href: SHIFTS_PATH, label: "Scheduling" }, { label: "Shifts" }], + canWrite: opts.canWrite, + chrome: opts.chrome, + ...(opts.error ? { error: opts.error } : {}), + filterBar: { + applyLabel: "Search", + clearHref: SHIFTS_PATH, + label: "Filter shifts", + pills: opts.q ? [{ label: "Search", remove: SHIFTS_PATH, value: opts.q }] : [], + rows: [[ + { label: "Search shifts", name: "q", placeholder: "Search title or assignee…", type: "search", value: opts.q }, + { type: "spacer" }, + ]], + }, + newHref: `${SHIFTS_PATH}/new`, + table: { + caption: "Shifts", + columns: [{ label: "Shift" }, { label: "Assignee" }, { label: "Start" }, { label: "End" }], + rows: opts.shifts.map((s) => ({ + cells: [{ rowHeader: { text: s.title } }, s.assignee, s.start, s.end], + name: s.title, + })), + }, + title: "Shifts", + }; +} + +export function buildFormModel(opts: { chrome: PageChrome; errors?: Record; formError?: string; values?: Partial }) { + const v = opts.values ?? {}; + const e = opts.errors ?? {}; + const field = (cfg: { icon?: string; id: string; label: string; type?: string; value: string }) => ({ + ...cfg, name: cfg.id, ...(e[cfg.id] ? { error: e[cfg.id] } : {}), ...(cfg.id === "title" || cfg.id === "assignee" ? { required: true } : {}), + }); + return { + breadcrumbs: [{ href: SHIFTS_PATH, label: "Shifts" }, { label: "New shift" }], + chrome: opts.chrome, + ...(opts.formError ? { formError: opts.formError } : {}), + form: { + action: SHIFTS_PATH, + cancelHref: SHIFTS_PATH, + csrfToken: opts.chrome.csrfToken, + fields: [ + field({ icon: "i-cal", id: "title", label: "Shift title", value: v.title ?? "" }), + field({ icon: "i-user", id: "assignee", label: "Assignee", value: v.assignee ?? "" }), + field({ id: "start", label: "Start", type: "datetime-local", value: v.start ?? "" }), + field({ id: "end", label: "End", type: "datetime-local", value: v.end ?? "" }), + ], + submitLabel: "Create shift", + }, + title: "New shift", + }; +} + +// ---- input + validation ------------------------------------------------------------- + +export function readInput(form: URLSearchParams): ShiftInput { + return { + assignee: (form.get("assignee") ?? "").trim(), + end: (form.get("end") ?? "").trim(), + start: (form.get("start") ?? "").trim(), + title: (form.get("title") ?? "").trim(), + }; +} + +// Required-field validation → { field: message } or null. Kept deliberately small; the upstream +// owns the real domain rules (overlap, capacity, …) and rejects with a 4xx the handler surfaces. +export function validate(input: ShiftInput): Record | null { + const errors: Record = {}; + if (!input.title) errors["title"] = "A shift needs a title."; + if (!input.assignee) errors["assignee"] = "Assign the shift to someone."; + return Object.keys(errors).length ? errors : null; +} + +// ---- handlers (factories bound to the upstream) ------------------------------------- + +export function listShifts(upstream: ShiftsUpstream): RouteHandler { + return async (ctx) => { + const q = parseListQuery(ctx.url).q; + let shifts: Shift[] = []; + let error: string | undefined; + try { + shifts = await upstream.list(); + } catch { + error = "Couldn't reach the scheduling service — try again shortly."; + } + const needle = q.toLowerCase(); + const rows = needle ? shifts.filter((s) => s.title.toLowerCase().includes(needle) || s.assignee.toLowerCase().includes(needle)) : shifts; + return { data: buildListModel({ canWrite: can(ctx, WRITE), chrome: ctx.chrome, ...(error ? { error } : {}), q, shifts: rows }), view: "shifts" }; + }; +} + +export function newShiftForm(): RouteHandler { + return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" }); +} + +export function createShift(upstream: ShiftsUpstream): RouteHandler { + return async (ctx) => { + const form = await readFormBody(ctx.req); + // A write is a first-party form, so guard it with the host's double-submit token (ctx.verifyCsrf). + if (!ctx.verifyCsrf(form.get(CSRF_FIELD))) throw new GuardError(403, "invalid CSRF token"); + const input = readInput(form); + const errors = validate(input); + if (errors) return { data: buildFormModel({ chrome: ctx.chrome, errors, values: input }), status: 400, view: "shift-new" }; + try { + await upstream.create(input); + } catch { + return { data: buildFormModel({ chrome: ctx.chrome, formError: "Couldn't save the shift — the scheduling service is unavailable.", values: input }), status: 502, view: "shift-new" }; + } + return { redirect: SHIFTS_PATH }; // POST-redirect-GET + }; +} diff --git a/plugins/scheduling/views/partials/shift-form.ejs b/plugins/scheduling/views/partials/shift-form.ejs new file mode 100644 index 0000000..c5499c2 --- /dev/null +++ b/plugins/scheduling/views/partials/shift-form.ejs @@ -0,0 +1,22 @@ +<%# + A plugin's own partial (resolved before the core ones). The new-shift form body, reusing the core + `partials/field` + `partials/alert`. Config: form { action, csrfToken, submitLabel, cancelHref, + fields: field.ejs config[] }, formError? +%><% + const form = locals.form; +-%> +
+<% if (locals.formError) { -%> +<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %> +<% } -%> +
+ +<% form.fields.forEach((field) => { -%> + <%- include("partials/field", field) %> +<% }) -%> +
+ Cancel + +
+
+
diff --git a/plugins/scheduling/views/shift-new.ejs b/plugins/scheduling/views/shift-new.ejs new file mode 100644 index 0000000..a34e3bb --- /dev/null +++ b/plugins/scheduling/views/shift-new.ejs @@ -0,0 +1,19 @@ +<%# + Scheduling · New shift form (reference plugin). The form POSTs to /scheduling/shifts; the handler + CSRF-verifies and forwards the write upstream. Body comes from this plugin's OWN partial + (partials/shift-form — resolved plugin-first), which reuses the core field partial. + Data: chrome, title, breadcrumbs, form, formError? +%><% + const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); + const body = include("partials/shift-form", { form, formError: locals.formError }); +-%> +<%- include("partials/shell", { + body, + brand: chrome.brand, + breadcrumbs, + csrfToken: chrome.csrfToken, + nav: navHtml, + theme: chrome.theme, + title, + user: chrome.user, +}) %> diff --git a/plugins/scheduling/views/shifts.ejs b/plugins/scheduling/views/shifts.ejs new file mode 100644 index 0000000..9aa4c46 --- /dev/null +++ b/plugins/scheduling/views/shifts.ejs @@ -0,0 +1,26 @@ +<%# + Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream + service; this view renders them with the core building blocks inside the native app shell + (ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table, + alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL. + Data: chrome, title, breadcrumbs, filterBar, table, canWrite, newHref, error? +%><% + const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); + const filtersHtml = include("partials/filter-bar", filterBar); + const tableHtml = include("partials/data-table", table); + const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : ""; + const actions = canWrite + ? 'New shift' + : ""; +-%> +<%- include("partials/shell", { + actions, + body: alertHtml + filtersHtml + tableHtml, + brand: chrome.brand, + breadcrumbs, + csrfToken: chrome.csrfToken, + nav: navHtml, + theme: chrome.theme, + title, + user: chrome.user, +}) %> diff --git a/src/app.test.ts b/src/app.test.ts index 606b5f6..3383deb 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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((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" }; diff --git a/src/app.ts b/src/app.ts index 7acf7cc..c340ea2 100644 --- a/src/app.ts +++ b/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; } diff --git a/src/bootstrap.test.ts b/src/bootstrap.test.ts index 46fe1e7..fa5c247 100644 --- a/src/bootstrap.test.ts +++ b/src/bootstrap.test.ts @@ -30,7 +30,7 @@ test("roleTuple grants a role to user: 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/, ); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 57a817d..5dbd696 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -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 { @@ -80,15 +81,17 @@ export async function seedAdmin(opts: SeedOptions): Promise { 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 { @@ -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 })); } diff --git a/src/chrome.test.ts b/src/chrome.test.ts new file mode 100644 index 0000000..c6d98ec --- /dev/null +++ b/src/chrome.test.ts @@ -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"); +}); diff --git a/src/chrome.ts b/src/chrome.ts new file mode 100644 index 0000000..f475ed2 --- /dev/null +++ b/src/chrome.ts @@ -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; +} diff --git a/src/context.ts b/src/context.ts index 5facbdf..e0b75e8 100644 --- a/src/context.ts +++ b/src/context.ts @@ -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; // 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; 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 }; } diff --git a/src/dashboard.test.ts b/src/dashboard.test.ts index fd2e850..a89be6d 100644 --- a/src/dashboard.test.ts +++ b/src/dashboard.test.ts @@ -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 diff --git a/src/dashboard.ts b/src/dashboard.ts index 55e674c..f4c49e5 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -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 = {}): 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; -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); } diff --git a/todo.md b/todo.md index 5d3cd7b..d50ed24 100644 --- a/todo.md +++ b/todo.md @@ -110,7 +110,7 @@ everything via Docker. - [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §6 Hydra/OAuth2 accretion (the per-module matrices in `hydra-admin`/`oauth-login`/`admin-clients` are one-contract-per-test — no fat). Removed the genuine §6 overlaps: (1) the stale-4xx→400 / outage-5xx→500 degrade was **triplicated** across the `app.test.ts` `/oauth2/login`, `/consent`, `/logout` tests with near-identical app-spin-up boilerplate — production aims for "byte-identical" degrade across the three, so it's now one parametrized test (`OAuth2 challenge endpoints degrade identically`) iterating the three endpoints × {410→400, 503→500}, which both removes ~27 lines and makes the shared contract explicit/enforced; the three endpoint tests keep their happy-path + missing-challenge→400. (2) `oauth-consent.test.ts`: merged the two consent-screen view tests (account named when signed in / omitted when not — same `view` surface, one variable) and the two `acceptConsent` grant tests (scope re-read + id_token on subject-match / omitted on mismatch — same method's grant body). Pure test refactor, no production code touched; every assertion preserved. 279 → 278 units; typecheck + tests green. ## 7. Example plugin (reference) -- [ ] Reference plugin (e.g. people directory or scheduling): list page fetching upstream data, a form that forwards writes upstream, permission-gated nav. +- [x] Reference plugin (e.g. people directory or scheduling): list page fetching upstream data, a form that forwards writes upstream, permission-gated nav. → `plugins/scheduling/` is the worked example the docs already reference (so contract + reference agree). `shifts.ts` = an injectable-`fetch` upstream REST client (`createUpstream`, stand-in for the customer's backend — the plugin is stateless) + thin handler **factories** bound to it: `listShifts` fetches `/shifts`, filters by `?q`, renders the data-table (upstream down ⇒ a recoverable error page, never a host 500); `newShiftForm` renders the form; `createShift` reads its own body, **CSRF-guards via `ctx.verifyCsrf`** (403 on a bad token), validates, forwards the create upstream, then POST-redirect-GET (a 4xx upstream ⇒ a recoverable 502 form keeping the input). `plugin.ts` = the manifest: `apiVersion` literal, namespaced `scheduling:read`/`scheduling:write` perms, **permission-gated nav** ("Shifts" gated on `read` so the whole "Scheduling" header vanishes for non-holders), routes gated `read`/`write`. Views (`shifts.ejs`, `shift-new.ejs` + the plugin's **own** `partials/shift-form.ejs`) compose the core building blocks (shell/nav-tree/filter-bar/data-table/field/alert via `include()`) around the **native app shell**. **New host capability so a plugin page is native + secure** (`src/chrome.ts` `buildPluginChrome`): `ctx.chrome` = brand/global-nav/user/theme/csrf the view hands to `partials/shell` — the global menu (a Dashboard link + every discovered plugin's nav fragment + the gated admin section), composed + role-filtered + current-marked by request path; `ctx.verifyCsrf(submitted)` = the host's bound double-submit verifier (plugin never sees the secret). Both added to `RequestContext` (defaulted in `buildContext`, anonymous chrome / fail-closed verify), built per plugin route in `app.ts` (CSRF cookie set when fresh so forms carry a token). The dashboard now merges plugin nav fragments too (reachable from `/`; gated ⇒ invisible to anonymous, so the visual E2E is byte-identical). Out of the box: bootstrap now grants the demo admin `scheduling:read`/`scheduling:write` (generalized `seedAdmin` to a roles list, env `ADMIN_ROLES`); the dev compose runs a tiny stdlib mock upstream (`examples/shifts-upstream/`, `SCHEDULING_UPSTREAM`) so `docker compose up` shows it working. Tooling: `plugins/` added to tsconfig + the `npm test` glob (so plugin authors' tests run via `docker compose run web npm test`). Tests-first: `plugins/scheduling/shifts.test.ts` (client w/ mock fetch · validation · list/create handlers incl. CSRF-403, validation-400, PRG, upstream-502 · form model), `src/chrome.test.ts` (brand/nav/role-filter/current/branding), `app.test.ts` (a plugin view renders the chrome + the CSRF round-trip over HTTP), `dashboard.test.ts` (plugin-fragment merge, gated), `bootstrap.test.ts` (multi-role grant). README **Building a plugin** + Layout and `docs/plugin-contract.md` (the `ctx.chrome`/`ctx.verifyCsrf` additions, the upstream pattern, the dev/test pointer) updated. typecheck + **296 units** green; the Ory-free **visual E2E** (real built image) confirms the plugin is discovered at boot, the routes/nav are permission-gated (anonymous → 403, hidden from the dashboard), and the dashboard still renders identically; live full-stack boot-verified — the stack comes up with the plugin + mock upstream, the upstream serves the seeded shifts and is reachable from `web`, and bootstrap grants the admin `admin`/`scheduling:read`/`scheduling:write` in real Keto (all `allowed:true`); torn down. The authenticated browser happy-path (login → rendered list) is deferred to §8's full E2E (line 114 verifies the contract end-to-end) — it needs the cross-host Playwright login infra, not curl. `apiVersion` stays `1.0.0` (the contract is still being assembled in §7, so chrome/verifyCsrf are part of the initial surface — no minor bump, no warn noise). - [ ] Verify the full plugin contract end-to-end against the README. - [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. diff --git a/tsconfig.json b/tsconfig.json index 06030dd..aba6864 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, - "include": ["config", "src"] + "include": ["config", "plugins", "src"] }