From 4e97fb619ec0c27e5a7f75920f33eaea385b9010 Mon Sep 17 00:00:00 2001 From: lilleman Date: Fri, 19 Jun 2026 15:31:53 +0200 Subject: [PATCH] =?UTF-8?q?=C2=A77=20review=20checkpoint=20(todo=20=C2=A77?= =?UTF-8?q?);=20ran=20the=20architecture=20+=20product=20reviewers=20on=20?= =?UTF-8?q?the=20whole=20project=20and=20addressed=20findings,=20no=20Crit?= =?UTF-8?q?ical=20from=20either.=20Made=20`permissions`=20honest=20+=20dec?= =?UTF-8?q?oupled=20the=20host=20from=20the=20plugin:=20new=20pure=20seedR?= =?UTF-8?q?oles=20+=20bootstrap=20discoverPlugins()=20seeds=20the=20demo?= =?UTF-8?q?=20admin=20`admin`(/ADMIN=5FROLES)=20=E2=88=AA=20every=20discov?= =?UTF-8?q?ered=20plugin's=20declared=20tokens,=20dropped=20the=20hardcode?= =?UTF-8?q?d=20scheduling:*=20from=20compose=20ADMIN=5FROLES=20(clean-clon?= =?UTF-8?q?e=20unchanged);=20docs=20now=20state=20a=20route/nav=20`permiss?= =?UTF-8?q?ion`=20is=20a=20coarse=20role=20granted=20as=20Keto=20Role:#members.=20Added=20src/plugin-api.ts=20=E2=80=94=20the=20st?= =?UTF-8?q?able=20author=20barrel=20the=20reference=20plugin=20now=20impor?= =?UTF-8?q?ts=20from=20instead=20of=20deep=20src/*=20(the=20contract=20bou?= =?UTF-8?q?ndary=20in=20code).=20Made=20per-plugin=20CSS=20usable:=20shell?= =?UTF-8?q?=20`styles`=20slot=20+=20plugins/scheduling/public/scheduling.c?= =?UTF-8?q?ss=20linked=20from=20the=20views.=20Reference=20now=20demonstra?= =?UTF-8?q?tes=20hooks.onBoot=20validating=20SCHEDULING=5FUPSTREAM=20fail-?= =?UTF-8?q?loud=20(assertHttpUrl).=20Build=20ctx.chrome=20at=20most=20once?= =?UTF-8?q?=20per=20request=20(memoized).=20Doc=20honesty:=20fixed=20the?= =?UTF-8?q?=20false=20visual.spec=20coverage=20comment,=20softened=20the?= =?UTF-8?q?=20"every=20plugin=20ships=20a=20Playwright=20test"=20claim=20(?= =?UTF-8?q?authed=20flow=20=3D=20=C2=A78),=20added=20an=20Upstream=20contr?= =?UTF-8?q?act=20block=20to=20the=20plugin=20README.=20Added=20LICENSE=20(?= =?UTF-8?q?MIT).=20Stability-reviewer=20APPROVE,=20no=20Critical/High;=20a?= =?UTF-8?q?ddressed=20both=20Low=20nits.=20typecheck=20+=20301=20units=20g?= =?UTF-8?q?reen.=20Deferred:=20internal=20route-table=20(M1)=E2=86=92?= =?UTF-8?q?=C2=A79,=20safeUrl()=E2=86=92=C2=A79,=20data-table=20empty-stat?= =?UTF-8?q?e=20+=20success-flash=E2=86=92=C2=A78/polish,=20apiVersion-lite?= =?UTF-8?q?ral=20enforcement=20(prose),=20permission=E2=86=92requireRole?= =?UTF-8?q?=20rename=20(future=20minor).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LICENSE | 21 +++++++++++++ README.md | 17 ++++++----- compose.yml | 5 ++-- docs/plugin-contract.md | 38 +++++++++++++++++------- e2e/visual.spec.ts | 2 +- plugins/scheduling/README.md | 19 ++++++++++-- plugins/scheduling/plugin.ts | 11 +++++-- plugins/scheduling/public/scheduling.css | 6 ++++ plugins/scheduling/shifts.test.ts | 25 +++++++++++++++- plugins/scheduling/shifts.ts | 20 +++++++++---- plugins/scheduling/views/shift-new.ejs | 3 +- plugins/scheduling/views/shifts.ejs | 3 +- src/app.ts | 16 ++++++---- src/bootstrap.test.ts | 12 +++++++- src/bootstrap.ts | 19 ++++++++++-- src/plugin-api.test.ts | 14 +++++++++ src/plugin-api.ts | 15 ++++++++++ src/shell.test.ts | 9 ++++++ todo.md | 2 +- views/partials/shell.ejs | 7 +++-- 20 files changed, 214 insertions(+), 50 deletions(-) create mode 100644 LICENSE create mode 100644 plugins/scheduling/public/scheduling.css create mode 100644 src/plugin-api.test.ts create mode 100644 src/plugin-api.ts diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..02d5c6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Larv IT AB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index dc386b3..1e4639e 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,9 @@ docker compose up # http://localhost:3000, live reload via `node --wa merging `compose.override.yml`, which mounts the source and restarts the server on change. A one-shot `bootstrap` service then seeds first-boot state with **zero manual prep** — it generates the JWT signing key if absent, creates a demo admin -(`admin@plainpages.local` / `admin`) in Kratos, and grants it the `admin` role in Keto -so permission checks resolve out of the box; it is idempotent, so every `up` re-runs it +(`admin@plainpages.local` / `admin`) in Kratos, and grants it the `admin` role plus every +discovered plugin's declared permission tokens in Keto, so permission checks (and any dropped-in +plugin) resolve out of the box; it is idempotent, so every `up` re-runs it safely. It finishes by printing a banner with the login URL and seeded credentials. **Change the demo admin before production.** The web app waits for Kratos + Keto to be healthy *and* the bootstrap to finish before starting (each Ory service has a @@ -282,15 +283,14 @@ The manifest is **TypeScript** — typed, commented, no separate schema to keep sync. The `id` and mount path are **derived from the folder name**, not declared: ```ts -import { definePlugin } from "../../src/plugin.ts"; +import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs) import { listShifts } from "./shifts.ts"; export default definePlugin({ apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see docs) - // Nav fragment, composed into the global menu. Permission-gated via Keto: - // items the current user can't access are hidden. Arbitrary depth. - // `icon` is a Lucide icon by its sprite id (src/icons.ts). + // Nav fragment, composed into the global menu. Permission-gated: items the current user can't + // access are hidden. Arbitrary depth. `icon` is a Lucide icon by its sprite id (src/icons.ts). nav: [ { label: "Scheduling", icon: "i-cal", @@ -300,8 +300,8 @@ export default definePlugin({ }, ], - // Route handlers, mounted under the plugin's path (/scheduling). `permission` - // (a Keto check) is enforced before the handler runs. + // Route handlers, mounted under the plugin's path (/scheduling). `permission` is a coarse role + // (a JWT-claim check) enforced before the handler runs. routes: [ { method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts }, ], @@ -585,6 +585,7 @@ src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, p 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 Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath() +src/plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers) src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2) src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2) src/view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials (§2) diff --git a/compose.yml b/compose.yml index 9a303f1..fb777d1 100644 --- a/compose.yml +++ b/compose.yml @@ -122,8 +122,9 @@ 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} + # Base roles for the demo admin; bootstrap also grants every discovered plugin's declared + # permission tokens (so the reference plugin — and any drop-in — works out of the box). + ADMIN_ROLES: ${ADMIN_ROLES:-admin} 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 6b641bb..89653da 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -55,8 +55,13 @@ Nothing else references it; the operator stays in control through the central me ## The manifest +A plugin imports its host surface from one module — `src/plugin-api.ts`, the **stable author +barrel** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the +body/CSRF/list-query helpers). That barrel *is* the contract boundary; don't reach into deeper +`src/*` modules — the host may refactor those freely as long as the barrel holds. + ```ts -import { definePlugin } from "../../src/plugin.ts"; +import { definePlugin } from "../../src/plugin-api.ts"; import { listShifts, createShift } from "./shifts.ts"; export default definePlugin({ @@ -69,7 +74,8 @@ export default definePlugin({ children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], }], - // Permission tokens this plugin introduces (for docs + Keto seeding). Optional. + // Permission tokens this plugin introduces. Declared for documentation, conflict detection, and + // bootstrap seeding (the demo admin is granted every discovered plugin's tokens). Optional. permissions: [ { token: "scheduling:read", description: "View shifts" }, { token: "scheduling:write", description: "Create and edit shifts" }, @@ -93,7 +99,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder | --- | --- | --- | | `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. | -| `permissions` | no | Tokens this plugin introduces; declared for documentation and seeding. | +| `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). | | `routes` | no | See [Routes & handlers](#routes--handlers). | | `hooks` | no | See [Hooks](#hooks). | @@ -122,8 +128,7 @@ type RouteResult = ```ts // shifts.ts -import type { RequestContext } from "../../src/context.ts"; -import { parseListQuery } from "../../src/list-query.ts"; +import { parseListQuery, type RequestContext } from "../../src/plugin-api.ts"; export async function listShifts(ctx: RequestContext) { const q = parseListQuery(ctx.url); @@ -135,8 +140,10 @@ export async function listShifts(ctx: RequestContext) { - **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()` the core building-block partials (app shell, nav tree, data table, …) and its own - partials/subfolders to render a full page — exactly as the built-in screens do. -- **Finer authorization than the route `permission`** uses the guards in `src/guards.ts`: + partials/subfolders to render a full page — exactly as the built-in screens do. To load the + plugin's own CSS, pass its `/public//x.css` href in the shell's `styles` slot (an array of + extra stylesheet hrefs) — see the reference's `views/shifts.ejs`. +- **Finer authorization than the route `permission`** uses the guards from `src/plugin-api.ts`: `requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx, {namespace, object, relation})` (a live Keto check for relationship rules — the subject is the @@ -205,10 +212,18 @@ depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` i icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are `ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there. +**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles` +include the token; those roles come from Keto at login, so an operator grants a token by writing the +Keto tuple `Role:#members@user:` (or to a group) — the admin **Roles** screen does this. +(The fine-grained, per-row tier is the separate Keto `Resource` namespace — see the README's *Three +tiers of "may I?"*; it is not what a route `permission` checks.) + 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). +optional but recommended: it documents them, feeds conflict detection, and lets the one-command +bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens (§3), so +a dropped-in plugin works out of the box without editing host config. ## Contract versioning @@ -293,9 +308,10 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste 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. +3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in + `e2e/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web` + service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`; + its authenticated list/form happy-path is the §8 full-E2E item (needs cross-host login infra). 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/e2e/visual.spec.ts b/e2e/visual.spec.ts index 2a3e168..e246126 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -124,7 +124,7 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to // 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. +// flow needs cross-host login infra — deferred to the §8 full E2E, todo line 121). 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); diff --git a/plugins/scheduling/README.md b/plugins/scheduling/README.md index fb3d33e..06273da 100644 --- a/plugins/scheduling/README.md +++ b/plugins/scheduling/README.md @@ -20,9 +20,22 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*). ## 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. +Set `SCHEDULING_UPSTREAM` to your backend's base URL. 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. +A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook). + +### Upstream contract + +Your backend must expose two routes; the plugin treats any non-2xx as a recoverable failure +(the list degrades to a "try again" alert, the create re-renders the form keeping the input). + +| Route | Request | Success | Response body | +| --- | --- | --- | --- | +| `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) | +| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) | + +Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the +form re-renders. The plugin only validates that `title` and `assignee` are non-empty. ## Granting access diff --git a/plugins/scheduling/plugin.ts b/plugins/scheduling/plugin.ts index 283dece..ec71631 100644 --- a/plugins/scheduling/plugin.ts +++ b/plugins/scheduling/plugin.ts @@ -2,16 +2,21 @@ // 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"; +import { definePlugin } from "../../src/plugin-api.ts"; +import { assertHttpUrl, 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"); +const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000"; +const upstream = createUpstream(upstreamUrl); export default definePlugin({ apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION + // onBoot runs after discovery, before the server listens: validate the plugin's own config so a + // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. + hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") }, + // 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: [{ diff --git a/plugins/scheduling/public/scheduling.css b/plugins/scheduling/public/scheduling.css new file mode 100644 index 0000000..d284249 --- /dev/null +++ b/plugins/scheduling/public/scheduling.css @@ -0,0 +1,6 @@ +/* Scheduling plugin styles — served at /public/scheduling/scheduling.css (per-plugin static), + linked via the shell `styles` slot. The core design system already styles every building block, + so a plugin only adds what's domain-specific, scoped under its own page class (.scheduling-page). */ +.scheduling-page .table-wrap { + margin-top: var(--space-3, 0.75rem); +} diff --git a/plugins/scheduling/shifts.test.ts b/plugins/scheduling/shifts.test.ts index e58eb73..c70a60d 100644 --- a/plugins/scheduling/shifts.test.ts +++ b/plugins/scheduling/shifts.test.ts @@ -7,7 +7,7 @@ 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, + assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, readInput, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, } from "./shifts.ts"; @@ -33,6 +33,29 @@ const asView = (r: RouteResult | void) => { return r as { data: Record; status?: number; view: string }; }; +// ---- upstream config validation (the onBoot hook) ---- + +test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => { + assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM")); + assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM")); + assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable + assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme + assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme +}); + +test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => { + const prev = process.env["SCHEDULING_UPSTREAM"]; + process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below + try { + const manifest = (await import("./plugin.ts")).default; + assert.equal(typeof manifest.hooks?.onBoot, "function"); + assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud + } finally { + if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"]; + else process.env["SCHEDULING_UPSTREAM"] = prev; + } +}); + // ---- upstream client (fetch injected) ---- test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", async () => { diff --git a/plugins/scheduling/shifts.ts b/plugins/scheduling/shifts.ts index 170ebc5..28753b0 100644 --- a/plugins/scheduling/shifts.ts +++ b/plugins/scheduling/shifts.ts @@ -5,12 +5,8 @@ // 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"; +// One import from the host's plugin-api barrel — the stable author surface (see docs/plugin-contract.md). +import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler } from "../../src/plugin-api.ts"; export const SHIFTS_PATH = "/scheduling/shifts"; export const READ = "scheduling:read"; // permission token gating the list + nav @@ -46,6 +42,18 @@ export interface ShiftsUpstream { list(): Promise; } +// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config +// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern. +export function assertHttpUrl(value: string, name: string): void { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`); + } + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`); +} + // 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 { diff --git a/plugins/scheduling/views/shift-new.ejs b/plugins/scheduling/views/shift-new.ejs index a34e3bb..39851e2 100644 --- a/plugins/scheduling/views/shift-new.ejs +++ b/plugins/scheduling/views/shift-new.ejs @@ -5,7 +5,7 @@ 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 }); + const body = '
' + include("partials/shift-form", { form, formError: locals.formError }) + '
'; -%> <%- include("partials/shell", { body, @@ -13,6 +13,7 @@ breadcrumbs, csrfToken: chrome.csrfToken, nav: navHtml, + styles: ["/public/scheduling/scheduling.css"], theme: chrome.theme, title, user: chrome.user, diff --git a/plugins/scheduling/views/shifts.ejs b/plugins/scheduling/views/shifts.ejs index 9aa4c46..f6aee06 100644 --- a/plugins/scheduling/views/shifts.ejs +++ b/plugins/scheduling/views/shifts.ejs @@ -15,11 +15,12 @@ -%> <%- include("partials/shell", { actions, - body: alertHtml + filtersHtml + tableHtml, + body: '
' + alertHtml + filtersHtml + tableHtml + '
', brand: chrome.brand, breadcrumbs, csrfToken: chrome.csrfToken, nav: navHtml, + styles: ["/public/scheduling/scheduling.css"], theme: chrome.theme, title, user: chrome.user, diff --git a/src/app.ts b/src/app.ts index c340ea2..16b266c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,7 +9,7 @@ import { type AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts"; import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts"; import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts"; import { readFormBody } from "./body.ts"; -import { buildPluginChrome } from "./chrome.ts"; +import { buildPluginChrome, type PageChrome } from "./chrome.ts"; import { buildContext, type User } from "./context.ts"; import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts"; import { buildDashboardModel } from "./dashboard.ts"; @@ -136,11 +136,16 @@ export function createApp(options: AppOptions = {}): Server { // Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret). const verifyCsrf = (submitted: string | null | undefined): boolean => verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted }); - // base context (no route params yet); reused for onRequest. Chrome is built lazily — only - // plugin routes (and an onRequest short-circuit) read ctx.chrome, so the hot path stays free. + // Chrome (brand/global-nav/user/theme/csrf) is built lazily and at most once per request — + // only plugin routes (and an onRequest short-circuit) read it, so the hot path stays free and + // a matched plugin request doesn't re-compose the whole menu for the onRequest + route ctx. + let chromeMemo: PageChrome | undefined; + const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user })); + + // base context (no route params yet); reused for onRequest. const ctx = buildContext(req, res, { user, verifyCsrf, - ...(anyRequestHooks ? { chrome: buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }) } : {}), + ...(anyRequestHooks ? { chrome: chrome() } : {}), }); // Plugin onRequest hooks run before routing and may short-circuit the request. @@ -157,8 +162,7 @@ export function createApp(options: AppOptions = {}): Server { // CSRF cookie is set so those forms have a valid double-submit token. const match = matchRoute(plugins, method, pathname); if (match) { - const chrome = buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }); - const routeCtx = buildContext(req, res, { chrome, params: match.params, user, verifyCsrf }); + const routeCtx = buildContext(req, res, { chrome: chrome(), params: match.params, user, verifyCsrf }); if (!isAuthorized(match.route, routeCtx.roles)) { sendHtml(res, 403, await render("403", { title: "Forbidden" })); return; diff --git a/src/bootstrap.test.ts b/src/bootstrap.test.ts index fa5c247..7d33215 100644 --- a/src/bootstrap.test.ts +++ b/src/bootstrap.test.ts @@ -5,7 +5,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin } from "./bootstrap.ts"; +import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts"; const json = (status: number, body?: unknown) => new Response(body === undefined ? null : JSON.stringify(body), { @@ -30,6 +30,16 @@ test("roleTuple grants a role to user: in the Role namespace", () => { }); }); +test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => { + // Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin + // gets exactly today's behaviour, but derived from discovery, not hardcoded in the host. + assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]); + assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role + assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended + assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant + assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides) +}); + test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => { const id = randomUUID(); const calls: { method: string; url: string; body?: unknown }[] = []; diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 5dbd696..e5e22ff 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -3,10 +3,12 @@ // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; // 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus -// the reference plugin's `scheduling:read`/`scheduling:write`, so the shipped example works. +// every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by +// the demo admin with no host config edit (the host stays plugin-agnostic). // Then prints a first-run banner; fails loud on any unexpected upstream error. import { existsSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import { discoverPlugins } from "./discovery.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts"; // --- Pure payload builders (the Kratos/Keto request contracts) ----------------------- @@ -25,6 +27,15 @@ export function roleTuple(identityId: string, role: string) { return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` }; } +// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`) +// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a +// coarse role — granted as a Keto `Role:#members` tuple). So the host names no plugin, yet a +// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped. +export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] { + const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean); + return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])]; +} + // --- JWKS safety net ----------------------------------------------------------------- export interface JwksFsHooks { @@ -124,8 +135,10 @@ async function main() { const env = process.env; if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) console.log("bootstrap: generated a JWKS signing key"); - // Default roles include the reference plugin's tokens so the shipped example works out of the box. - const roles = (env["ADMIN_ROLES"] ?? "admin,scheduling:read,scheduling:write").split(",").map((r) => r.trim()).filter(Boolean); + // Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the + // shipped example — and any dropped-in plugin — works for the demo admin without a host edit. + const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token)); + const roles = seedRoles(env["ADMIN_ROLES"], declared); const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const password = env["ADMIN_PASSWORD"] ?? "admin"; const result = await seedAdmin({ diff --git a/src/plugin-api.test.ts b/src/plugin-api.test.ts new file mode 100644 index 0000000..03922e9 --- /dev/null +++ b/src/plugin-api.test.ts @@ -0,0 +1,14 @@ +// The plugin author barrel (§7): the stable surface a plugin imports. Guards that the value exports +// stay present — removing one is a breaking contract change. The types resolve via typecheck (the +// reference plugin imports them from here). +import assert from "node:assert/strict"; +import test from "node:test"; +import * as api from "./plugin-api.ts"; + +test("plugin-api re-exports the stable author value surface", () => { + for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD"]) { + assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`); + } + assert.equal(typeof api.definePlugin, "function"); + assert.equal(api.definePlugin({ apiVersion: "1.0.0" }).apiVersion, "1.0.0"); // identity helper works through the barrel +}); diff --git a/src/plugin-api.ts b/src/plugin-api.ts new file mode 100644 index 0000000..08595e0 --- /dev/null +++ b/src/plugin-api.ts @@ -0,0 +1,15 @@ +// The plugin author surface (todo §7) — the ONE module a plugin imports. It re-exports exactly the +// stable contract: definePlugin + the manifest/handler types, the RequestContext, the auth guards, +// and the request-body/CSRF/list-query helpers the blessed pattern needs. This barrel *is* the +// contract boundary in code — the host may refactor any other src/* freely as long as it holds, so +// a plugin should import from here, never reach into deeper modules. See docs/plugin-contract.md. + +export { definePlugin } from "./plugin.ts"; +export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; +export type { RequestContext, User } from "./context.ts"; +export type { PageChrome } from "./chrome.ts"; +export type { NavNode } from "./nav.ts"; +export { can, check, GuardError, requireSession } from "./guards.ts"; +export { parseListQuery } from "./list-query.ts"; +export { readFormBody } from "./body.ts"; +export { CSRF_FIELD } from "./csrf.ts"; diff --git a/src/shell.test.ts b/src/shell.test.ts index f2a64b7..2f70dd9 100644 --- a/src/shell.test.ts +++ b/src/shell.test.ts @@ -53,6 +53,15 @@ test("app shell renders a configured logo + default theme, falls back to the bra assert.match(plain, /id="theme-auto"\s+checked/); // theme-switch default }); +test("app shell links extra per-page stylesheets via the styles slot (e.g. a plugin's own CSS)", async () => { + const withCss = await render({ styles: ["/public/scheduling/scheduling.css"] }); + assert.match(withCss, //); // core stylesheet always present + assert.match(withCss, //); // the extra one + + const none = await render(); // no styles → only the core stylesheet + assert.equal((none.match(/rel="stylesheet"/g) ?? []).length, 1); +}); + test("app shell escapes text but passes slot HTML through, and renders with defaults", async () => { const escaped = await render({ title: "", body: "

raw

" }); assert.match(escaped, /<x><\/title>/); // user text is escaped diff --git a/todo.md b/todo.md index 62e0631..ff2f58a 100644 --- a/todo.md +++ b/todo.md @@ -112,7 +112,7 @@ everything via Docker. ## 7. Example plugin (reference) - [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). - [x] Verify the full plugin contract end-to-end against the README. → Cross-checked every contract claim in `README.md` (**Building a plugin**, **Where plugins live**, **Layout**) + `docs/plugin-contract.md` + `plugins/scheduling/README.md` against the implementation (`plugin.ts`/`discovery.ts`/`router.ts`/`view-resolver.ts`/`chrome.ts`/`context.ts`/`guards.ts`/`app.ts` `sendResult`) and the shipped reference plugin. **Contract holds end-to-end**: derived id+mount (no `id`/`basePath` in the manifest), `apiVersion` literal + `checkApiVersion` table, permission-gated nav (the "Scheduling" header vanishes without `scheduling:read`) + gated routes, every `RouteResult` shape incl. `view`+`status` and `redirect`-PRG, `ctx.chrome` → native app shell, `ctx.verifyCsrf` double-submit (403 on bad token), `can()`/`GuardError(403)`, the view-resolver rendering `plugins/<id>/views/*` while `include()`-ing **both** core partials (shell/filter-bar/data-table/field/alert/nav-tree — all present in `views/partials/`) and the plugin's own `partials/shift-form`, per-plugin static, upstream-fetch statelessness; all three reference-plugin icons (`i-cal`/`i-plus`/`i-user`) are registered in `src/icons.ts`. **One material doc drift found + fixed:** `docs/plugin-contract.md`'s reserved-id list was stale — it named 8 ids but `RESERVED_PLUGIN_IDS` has 10 (the §5 `admin` + §6 `oauth2` mounts were missing), so an author would wrongly think `plugins/admin/` or `plugins/oauth2/` was allowed when discovery refuses them (test-covered in `discovery.test.ts`). Updated the doc to list all 10 accurately (docs-only, no behaviour change). typecheck + 296 units green. -- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. +- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §7 reference-plugin surfaces). Architecture: **no Critical**; Product: **no Critical** this checkpoint. **Fixed now (tests-first):** (1) HIGH (arch + product Blocker) — the contract claimed declaring `permissions` "seeds Keto" but nothing wired it, and the host hardcoded the plugin-specific `scheduling:read`/`write` into `ADMIN_ROLES` (`compose.yml` + `bootstrap.ts`) — core knowing about a plugin. Made the claim true *and* removed the coupling: new pure `seedRoles(adminRolesEnv, declaredTokens)` (union+dedup, trims both sides); `bootstrap.main()` now `discoverPlugins()` → seeds the demo admin `admin`(/`ADMIN_ROLES`) ∪ every discovered plugin's declared tokens; `compose.yml` `ADMIN_ROLES` default dropped to just `admin` (host names no plugin). Clean-clone behaviour is unchanged (admin still ends up with the scheduling tokens, now derived via discovery, not hardcoded — `readRoles` enumerates the Role namespace so a granted token reaches the JWT). Docs corrected: a route/nav `permission` **is a coarse role** granted as a Keto `Role:<token>#members` tuple (vs the separate fine-grained `Resource` tier). (2) HIGH (arch) — no delineated plugin SDK: the reference plugin imported six deep `src/*` internals, none of them the documented-stable surface. Added `src/plugin-api.ts` — the one barrel a plugin imports (`definePlugin` + manifest/handler/ctx types + guards + body/CSRF/list-query helpers); repointed `plugins/scheduling/{plugin,shifts}.ts` to it, so the contract boundary now lives in code (the host can refactor internals freely behind it). (3) MEDIUM (arch L1 + product) — per-plugin CSS was documented (`/public/<id>/`) but unusable (the shell `<head>` had no slot) and the reference shipped no `public/`. Added an optional `styles` slot to `shell.ejs` (extra stylesheet hrefs, default `[]`, backward-compatible) + `plugins/scheduling/public/scheduling.css` linked from both reference views (`.scheduling-page` scope) — closes the only unexercised contract feature + the anatomy-diagram drift. (4) MEDIUM (arch M4) — the reference demonstrated no hook; added `hooks.onBoot` validating `SCHEDULING_UPSTREAM` fail-loud at boot (`assertHttpUrl` — unparseable or non-http(s) aborts the boot, a typo no longer degrades every request), closing the hooks-coverage gap. (5) MEDIUM (arch M3) — `ctx.chrome` was built twice on a matched plugin request when any onRequest hook exists; now built at most once per request (memoized lazy `chrome()`), hot path still chrome-free. (6) HIGH/MEDIUM (product) — doc honesty: fixed the false `visual.spec.ts` comment ("covered by the full-stack suites" — it isn't), softened the contract's "every plugin ships *with* a Playwright test" to match reality (the reference's authed list/form happy-path is the §8 item), and added an **Upstream contract** block (routes/JSON shape/status-code expectations) to `plugins/scheduling/README.md`. Stability-reviewer run as a local PR: **APPROVE, no Critical/High**; addressed both Low nits (`seedRoles` now trims/dedups declared tokens too; added an `onBoot`-binding seam test on the real manifest). typecheck + **301 units** green (296 → 301). **Deferred (reviewer-scoped, not the §7 checkpoint):** the host **internal route-table** (fold the admin/oauth if-ladder into one `{method,prefix,handler}` table, derive `RESERVED_PLUGIN_IDS`/`allowedMethods` from it — arch M1) → **§9** (now the top structural item; standalone change, not a review-fix); the `safeUrl()` href helper + a reference row that renders an untrusted URL through it (arch L2 / product 🟢) → **§9** (first untrusted-URL flow / redirect-allowlist work); the data-table **empty state** + **success-flash** after writes (product 🟡, recurring §5/§6 deferral, now visible in the exemplar) → **§8/polish**; compile-time `apiVersion`-literal enforcement (arch M2) → accept prose-only; rename route/nav `permission` → `requireRole` to match the mechanism (arch H1.3) → a future minor `apiVersion` bump. - [ ] 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. - [ ] 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. diff --git a/views/partials/shell.ejs b/views/partials/shell.ejs index 191d135..507634a 100644 --- a/views/partials/shell.ejs +++ b/views/partials/shell.ejs @@ -1,7 +1,8 @@ <%# App shell: sidebar (brand + nav slot + footer) · topbar · content slot. Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial), - `actions` (topbar buttons), `body` (page content). Text locals: `title`, `brand` + `actions` (topbar buttons), `body` (page content); `styles` is an optional array of + extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title`, `brand` ({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field). Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth. @@ -13,6 +14,7 @@ const nav = locals.nav || ""; const actions = locals.actions || ""; const body = locals.body || ""; + const styles = locals.styles || []; // extra per-page stylesheet hrefs (e.g. a plugin's own CSS) %><!doctype html> <html lang="en"> <head> @@ -20,7 +22,8 @@ <meta name="viewport" content="width=device-width, initial-scale=1" /> <title><%= title %> - + <% styles.forEach((href) => { %> + <% }) %>