§7 reference plugin (todo §7); plugins/scheduling is the worked example of the plugin contract — a list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, permission-gated nav. shifts.ts: an injectable-fetch upstream REST client (stateless stand-in for the customer backend) + thin handler factories (list filters by ?q + degrades to a recoverable page on upstream-down; create CSRF-guards via ctx.verifyCsrf, validates, forwards, PRG, 502 on upstream 4xx). plugin.ts: apiVersion literal, namespaced scheduling:read/write perms, nav gated so the whole Scheduling header vanishes for non-holders. Views compose the core building blocks around the native app shell, incl. the plugin's own partials/shift-form. New host capability so a plugin page is native + secure (src/chrome.ts buildPluginChrome): ctx.chrome = brand/global-nav/user/theme/csrf for partials/shell (global menu = Dashboard + every plugin nav fragment + gated admin section, role-filtered + current-marked); ctx.verifyCsrf = the host's bound double-submit verifier (secret stays in the host). Both added to RequestContext (defaulted in buildContext), built per plugin route in app.ts (CSRF cookie set when fresh). Dashboard merges plugin nav fragments too (gated => invisible to anonymous, visual E2E byte-identical). Out of the box: bootstrap grants the demo admin scheduling:read/write (seedAdmin generalized to a roles list, env ADMIN_ROLES); dev compose runs a tiny stdlib mock upstream (examples/shifts-upstream, SCHEDULING_UPSTREAM). plugins/ added to tsconfig + the npm test glob. Tests-first across shifts/chrome/app/dashboard/bootstrap. README Building-a-plugin + Layout and docs/plugin-contract.md (ctx.chrome/verifyCsrf, upstream pattern) updated. typecheck + 296 units + the Ory-free visual E2E green (plugin discovered at boot, routes/nav gated, dashboard unchanged); live full-stack boot-verified (stack up with plugin + mock upstream serving the seeded shifts, bootstrap grants in real Keto all allowed:true) then torn down. apiVersion stays 1.0.0 (contract still assembled in §7). Authenticated browser happy-path deferred to §8 full E2E (line 114).
This commit is contained in:
19
README.md
19
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 —
|
registration step, no central wiring. The full, authoritative API surface —
|
||||||
manifest shape, handler/`RequestContext` contract, versioning, conflict rules,
|
manifest shape, handler/`RequestContext` contract, versioning, conflict rules,
|
||||||
hooks, and the dev/test story — is **[docs/plugin-contract.md](docs/plugin-contract.md)**
|
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
|
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
|
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
|
it — the plugin holds no state of its own (see below); the reference points
|
||||||
**self-contained** (its own nav, routes, views, CSS), so installing one is "drop
|
`SCHEDULING_UPSTREAM` at its backend (the dev compose ships a tiny mock,
|
||||||
the folder, restart." An operator stays in control via a central override.
|
`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)
|
### 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-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/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/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/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/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
||||||
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
|
src/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)
|
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)
|
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)
|
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)
|
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
|
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
|
html-css-foundation/ HTML design mockups — the source for the building-block
|
||||||
|
|||||||
@@ -8,10 +8,21 @@ services:
|
|||||||
CACHE_TEMPLATES: "false"
|
CACHE_TEMPLATES: "false"
|
||||||
REQUIRE_SECURE_SECRETS: "false"
|
REQUIRE_SECURE_SECRETS: "false"
|
||||||
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
|
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:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /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).
|
# 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.
|
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||||
mailpit:
|
mailpit:
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
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
|
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
||||||
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
||||||
KETO_WRITE_URL: http://keto:4467
|
KETO_WRITE_URL: http://keto:4467
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ request:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface RequestContext {
|
interface RequestContext {
|
||||||
|
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
|
||||||
params: Record<string, string>; // path params from the route match, e.g. /shifts/:id → { id }
|
params: Record<string, string>; // path params from the route match, e.g. /shifts/:id → { id }
|
||||||
query: URLSearchParams; // alias of url.searchParams
|
query: URLSearchParams; // alias of url.searchParams
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
@@ -175,9 +176,19 @@ interface RequestContext {
|
|||||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||||||
url: URL;
|
url: URL;
|
||||||
user: User | null; // { id, email, roles } from the verified session JWT, or null
|
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
|
**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
|
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,
|
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
|
## Local dev & test story
|
||||||
|
|
||||||
A plugin is a normal folder of TypeScript, so an author tests it the same way the core is tested
|
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,
|
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
|
return a `RouteResult`. Test the data-shaping in isolation (mock `fetch`/upstream) with
|
||||||
|
|||||||
@@ -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("heading", { name: "Page not found" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Back home" })).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
|
||||||
|
});
|
||||||
|
|||||||
44
examples/shifts-upstream/server.mjs
Normal file
44
examples/shifts-upstream/server.mjs
Normal file
@@ -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}`));
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"dev": "node --watch src/server.ts",
|
"dev": "node --watch src/server.ts",
|
||||||
"gen-jwks": "node src/gen-jwks.ts",
|
"gen-jwks": "node src/gen-jwks.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test \"src/**/*.test.ts\""
|
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ejs": "3.1.10",
|
"ejs": "3.1.10",
|
||||||
|
|||||||
31
plugins/scheduling/README.md
Normal file
31
plugins/scheduling/README.md
Normal file
@@ -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.
|
||||||
36
plugins/scheduling/plugin.ts
Normal file
36
plugins/scheduling/plugin.ts
Normal file
@@ -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 `<id>:<action>`.
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
});
|
||||||
138
plugins/scheduling/shifts.test.ts
Normal file
138
plugins/scheduling/shifts.test.ts
Normal file
@@ -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> = {}): 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<string, unknown>; 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<string, string>).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);
|
||||||
|
});
|
||||||
192
plugins/scheduling/shifts.ts
Normal file
192
plugins/scheduling/shifts.ts
Normal file
@@ -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<void>;
|
||||||
|
list(): Promise<Shift[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<string, unknown>;
|
||||||
|
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<string, string>; formError?: string; values?: Partial<ShiftInput> }) {
|
||||||
|
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<string, string> | null {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
22
plugins/scheduling/views/partials/shift-form.ejs
Normal file
22
plugins/scheduling/views/partials/shift-form.ejs
Normal file
@@ -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;
|
||||||
|
-%>
|
||||||
|
<div class="form-page">
|
||||||
|
<% if (locals.formError) { -%>
|
||||||
|
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
|
||||||
|
<% } -%>
|
||||||
|
<form class="form-card" method="post" action="<%= form.action %>">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
|
<% form.fields.forEach((field) => { -%>
|
||||||
|
<%- include("partials/field", field) %>
|
||||||
|
<% }) -%>
|
||||||
|
<div class="form-actions">
|
||||||
|
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||||
|
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
19
plugins/scheduling/views/shift-new.ejs
Normal file
19
plugins/scheduling/views/shift-new.ejs
Normal file
@@ -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,
|
||||||
|
}) %>
|
||||||
26
plugins/scheduling/views/shifts.ejs
Normal file
26
plugins/scheduling/views/shifts.ejs
Normal file
@@ -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
|
||||||
|
? '<a class="btn btn-primary" href="' + newHref + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>New shift</a>'
|
||||||
|
: "";
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
actions,
|
||||||
|
body: alertHtml + filtersHtml + tableHtml,
|
||||||
|
brand: chrome.brand,
|
||||||
|
breadcrumbs,
|
||||||
|
csrfToken: chrome.csrfToken,
|
||||||
|
nav: navHtml,
|
||||||
|
theme: chrome.theme,
|
||||||
|
title,
|
||||||
|
user: chrome.user,
|
||||||
|
}) %>
|
||||||
@@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
|
|||||||
import { after, before, test, type TestContext } from "node:test";
|
import { after, before, test, type TestContext } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { createApp, type AppOptions } from "./app.ts";
|
import { createApp, type AppOptions } from "./app.ts";
|
||||||
|
import { readFormBody } from "./body.ts";
|
||||||
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
|
||||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||||
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.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);
|
assert.equal((await fetch(url + "/demo/nope")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a plugin view renders the native chrome; its forms are CSRF-guarded via ctx.verifyCsrf (§7)", async (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||||
|
mkdirSync(join(dir, "panelkit", "views"), { recursive: true });
|
||||||
|
// The view composes the core shell from ctx.chrome — branding, the global nav, the Sign-out form.
|
||||||
|
writeFileSync(join(dir, "panelkit", "views", "panel.ejs"),
|
||||||
|
`<%- include("partials/shell", { brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), title, user: chrome.user }) %>`);
|
||||||
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||||
|
|
||||||
|
const plugin: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
id: "panelkit",
|
||||||
|
nav: [{ href: "/panelkit/panel", icon: "i-grid", id: "panelkit", label: "Panel kit" }],
|
||||||
|
routes: [
|
||||||
|
{ handler: (ctx) => ({ data: { chrome: ctx.chrome, title: "Panel" }, view: "panel" }), method: "GET", path: "/panel" },
|
||||||
|
{
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const form = await readFormBody(ctx.req);
|
||||||
|
if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, "bad csrf");
|
||||||
|
return { redirect: "/panelkit/panel" };
|
||||||
|
},
|
||||||
|
method: "POST", path: "/save",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const secret = "test-csrf-secret";
|
||||||
|
const app = createApp({ csrfSecret: secret, plugins: [plugin], pluginsDir: dir });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
// GET renders the shell: branding (DEFAULT_MENU), the (ungated) plugin nav, and a CSRF cookie
|
||||||
|
// whose token is embedded in the Sign-out form (double-submit).
|
||||||
|
const res = await fetch(url + "/panelkit/panel");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.text();
|
||||||
|
assert.match(body, /class="brand-name">Plainpages/);
|
||||||
|
assert.match(body, /Panel kit/);
|
||||||
|
const cookieTok = /plainpages_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
|
||||||
|
assert.ok(cookieTok, "a plugin route issues the CSRF cookie when fresh");
|
||||||
|
assert.equal(/name="_csrf" value="([^"]+)"/.exec(body)?.[1], cookieTok);
|
||||||
|
|
||||||
|
// POST with no token → 403 (ctx.verifyCsrf fails closed); matching cookie + field → 303.
|
||||||
|
assert.equal((await fetch(url + "/panelkit/save", { method: "POST", redirect: "manual" })).status, 403);
|
||||||
|
const tok = issueCsrfToken(secret);
|
||||||
|
const ok = await fetch(url + "/panelkit/save", {
|
||||||
|
body: `_csrf=${encodeURIComponent(tok)}`,
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${tok}` },
|
||||||
|
method: "POST", redirect: "manual",
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 303);
|
||||||
|
});
|
||||||
|
|
||||||
// JWT middleware (§4): a verified session cookie populates ctx.user/roles, which the gate reads.
|
// JWT middleware (§4): a verified session cookie populates ctx.user/roles, which the gate reads.
|
||||||
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||||
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
||||||
|
|||||||
21
src/app.ts
21
src/app.ts
@@ -9,6 +9,7 @@ import { type AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts";
|
|||||||
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts";
|
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts";
|
||||||
import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts";
|
import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts";
|
||||||
import { readFormBody } from "./body.ts";
|
import { readFormBody } from "./body.ts";
|
||||||
|
import { buildPluginChrome } from "./chrome.ts";
|
||||||
import { buildContext, type User } from "./context.ts";
|
import { buildContext, type User } from "./context.ts";
|
||||||
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
|
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
|
||||||
import { buildDashboardModel } from "./dashboard.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
|
// 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).
|
// one (the form page below Set-Cookies it). Verified on our own state-changing routes (§4).
|
||||||
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
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.
|
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||||
if (anyRequestHooks) {
|
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);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
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)) {
|
if (!isAuthorized(match.route, routeCtx.roles)) {
|
||||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||||
const result = (await match.route.handler(routeCtx)) ?? null;
|
const result = (await match.route.handler(routeCtx)) ?? null;
|
||||||
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
|
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
|
||||||
await sendResult(res, result, (view, data) => renderView(match.plugin.id, view, data));
|
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.
|
// 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.
|
// 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 }));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ test("roleTuple grants a role to user:<id> in the Role namespace", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin on a fresh stack creates the identity and grants the role", async () => {
|
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const calls: { method: string; url: string; body?: unknown }[] = [];
|
const calls: { method: string; url: string; body?: unknown }[] = [];
|
||||||
const fetchImpl = (async (url, init) => {
|
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",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
role: "admin",
|
roles: ["admin", "scheduling:read"],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { created: true, id, role: "admin" });
|
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
|
||||||
const put = calls.find((c) => c.url.includes("relation-tuples"))!;
|
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
|
||||||
assert.equal(put.method, "PUT");
|
assert.equal(puts.length, 2); // one grant per role
|
||||||
assert.deepEqual(put.body, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
|
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 () => {
|
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",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
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}` });
|
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",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
role: "admin",
|
roles: ["admin"],
|
||||||
}),
|
}),
|
||||||
/Kratos/,
|
/Kratos/,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
// 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);
|
// 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;
|
// 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.
|
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||||
import { existsSync, writeFileSync } from "node:fs";
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -50,13 +51,13 @@ export interface SeedOptions {
|
|||||||
ketoWriteUrl: string;
|
ketoWriteUrl: string;
|
||||||
kratosAdminUrl: string;
|
kratosAdminUrl: string;
|
||||||
password: string;
|
password: string;
|
||||||
role: string;
|
roles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeedResult {
|
export interface SeedResult {
|
||||||
created: boolean;
|
created: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
role: string;
|
roles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
||||||
@@ -80,15 +81,17 @@ export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
|||||||
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
|
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.
|
// 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`, {
|
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
||||||
body: JSON.stringify(roleTuple(id, opts.role)),
|
body: JSON.stringify(roleTuple(id, role)),
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
});
|
});
|
||||||
if (!grant.ok) throw new Error(`bootstrap: Keto grant role failed (${grant.status}): ${await grant.text()}`);
|
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
return { created, id, role: opts.role };
|
return { created, id, roles: opts.roles };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
||||||
@@ -121,7 +124,8 @@ async function main() {
|
|||||||
const env = process.env;
|
const env = process.env;
|
||||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) console.log("bootstrap: generated a JWKS signing key");
|
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 email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||||
const result = await seedAdmin({
|
const result = await seedAdmin({
|
||||||
@@ -129,9 +133,9 @@ async function main() {
|
|||||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||||
password,
|
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 }));
|
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
49
src/chrome.test.ts
Normal file
49
src/chrome.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { buildPluginChrome } from "./chrome.ts";
|
||||||
|
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||||
|
import type { NavNode } from "./nav.ts";
|
||||||
|
import type { Plugin } from "./plugin.ts";
|
||||||
|
|
||||||
|
const scheduling: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
id: "scheduling",
|
||||||
|
nav: [{
|
||||||
|
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||||
|
icon: "i-cal", id: "scheduling", label: "Scheduling",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label);
|
||||||
|
|
||||||
|
test("anonymous: brand from menu, Guest user, gated plugin + admin nav filtered out", () => {
|
||||||
|
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling] });
|
||||||
|
assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name);
|
||||||
|
assert.equal(chrome.user.name, "Guest");
|
||||||
|
assert.deepEqual(labels(chrome.nav), ["Dashboard"]); // Scheduling (gated child) + Admin dropped
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a permission holder sees the plugin nav; current path opens the active leaf", () => {
|
||||||
|
const chrome = buildPluginChrome({
|
||||||
|
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
||||||
|
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
|
||||||
|
});
|
||||||
|
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]);
|
||||||
|
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
|
||||||
|
assert.equal(section.open, true); // ancestor of the current leaf opened
|
||||||
|
assert.equal(section.children!.find((c) => c.label === "Shifts")!.current, true);
|
||||||
|
assert.equal(chrome.user.name, "ada"); // email local part
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an admin sees the gated admin section", () => {
|
||||||
|
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
|
||||||
|
assert.ok(labels(chrome.nav).includes("Admin"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("branding logo + default theme flow through when set", () => {
|
||||||
|
const menu: MenuConfig = { branding: { logo: "/logo.svg", name: "Acme", theme: "dark" }, override: {} };
|
||||||
|
const chrome = buildPluginChrome({ menu });
|
||||||
|
assert.equal(chrome.brand.logo, "/logo.svg");
|
||||||
|
assert.equal(chrome.brand.name, "Acme");
|
||||||
|
assert.equal(chrome.theme, "dark");
|
||||||
|
});
|
||||||
68
src/chrome.ts
Normal file
68
src/chrome.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// Page chrome for plugin pages (todo §7): the brand / global-nav / user / theme / csrf block a
|
||||||
|
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
|
||||||
|
// admin screens render. Pure; the host builds it once per plugin request (it has the menu config,
|
||||||
|
// the discovered plugins, the signed-in user and the request CSRF token) and exposes it on
|
||||||
|
// ctx.chrome. The nav is the global menu — a Dashboard home link, every discovered plugin's nav
|
||||||
|
// fragment, and the gated admin section — run through composeNav (override + per-user role filter),
|
||||||
|
// with the node whose href matches the current path marked `current` (its ancestors opened).
|
||||||
|
|
||||||
|
import { adminSection } from "./admin-nav.ts";
|
||||||
|
import type { User } from "./context.ts";
|
||||||
|
import { type MenuConfig } from "./menu-config.ts";
|
||||||
|
import { composeNav, type NavNode } from "./nav.ts";
|
||||||
|
import type { Plugin } from "./plugin.ts";
|
||||||
|
import { shellUser, type ShellUser } from "./shell-context.ts";
|
||||||
|
|
||||||
|
export interface PageChrome {
|
||||||
|
brand: { logo?: string; name: string; sub?: string };
|
||||||
|
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
||||||
|
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs
|
||||||
|
theme?: string;
|
||||||
|
user: ShellUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOME: NavNode = { href: "/", icon: "i-grid", id: "dashboard", label: "Dashboard" };
|
||||||
|
|
||||||
|
export interface ChromeOptions {
|
||||||
|
csrfToken?: string;
|
||||||
|
currentPath?: string; // request pathname; the matching nav leaf is marked current
|
||||||
|
menu: MenuConfig;
|
||||||
|
plugins?: Plugin[];
|
||||||
|
user?: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||||
|
const fragments: NavNode[][] = [[HOME]];
|
||||||
|
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
||||||
|
fragments.push([adminSection()]);
|
||||||
|
|
||||||
|
const roles = opts.user?.roles ?? [];
|
||||||
|
const nav = composeNav(fragments, opts.menu.override, roles);
|
||||||
|
if (opts.currentPath) markCurrent(nav, opts.currentPath);
|
||||||
|
|
||||||
|
const b = opts.menu.branding;
|
||||||
|
return {
|
||||||
|
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
|
||||||
|
csrfToken: opts.csrfToken ?? "",
|
||||||
|
nav,
|
||||||
|
...(b.theme != null ? { theme: b.theme } : {}),
|
||||||
|
user: shellUser(opts.user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark the leaf whose href equals `path` as current and open every ancestor header so the active
|
||||||
|
// page is revealed. Mutates the freshly-composed nodes (composeNav returns new objects each call).
|
||||||
|
// Returns whether this subtree contains the current node.
|
||||||
|
function markCurrent(nodes: NavNode[], path: string): boolean {
|
||||||
|
let hit = false;
|
||||||
|
for (const node of nodes) {
|
||||||
|
const here = node.href === path;
|
||||||
|
const inChild = node.children ? markCurrent(node.children, path) : false;
|
||||||
|
if (here) node.current = true;
|
||||||
|
if (here || inChild) {
|
||||||
|
if (node.children) node.open = true;
|
||||||
|
hit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { 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
|
// 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
|
// per request by `buildContext`: the router supplies matched path `params`, the §4 JWT
|
||||||
@@ -13,6 +14,9 @@ export interface User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestContext {
|
export interface RequestContext {
|
||||||
|
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
|
||||||
|
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
|
||||||
|
chrome: PageChrome;
|
||||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
@@ -20,13 +24,21 @@ export interface RequestContext {
|
|||||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||||||
url: URL;
|
url: URL;
|
||||||
user: User | null;
|
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 {
|
export interface BuildContextOptions {
|
||||||
|
chrome?: PageChrome;
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
user?: User | null;
|
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(
|
export function buildContext(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
@@ -35,6 +47,7 @@ export function buildContext(
|
|||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
const user = options.user ?? null;
|
const user = options.user ?? null;
|
||||||
return {
|
return {
|
||||||
|
chrome: options.chrome ?? ANON_CHROME,
|
||||||
params: options.params ?? {},
|
params: options.params ?? {},
|
||||||
query: url.searchParams,
|
query: url.searchParams,
|
||||||
req,
|
req,
|
||||||
@@ -42,5 +55,6 @@ export function buildContext(
|
|||||||
roles: user?.roles ?? [],
|
roles: user?.roles ?? [],
|
||||||
url,
|
url,
|
||||||
user,
|
user,
|
||||||
|
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,20 @@ test("dashboard menu wires in the permission-gated Admin section (only for admin
|
|||||||
assert.ok(!plain.nav.some((n) => n.children?.some((c) => c.href === "/admin/users")));
|
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", () => {
|
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"));
|
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
|
assert.equal(p2.pagination.summary.from, 13); // 30 rows / 12 per page → page 2 starts at 13
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { adminSection } from "./admin-nav.ts";
|
|||||||
import type { User } from "./context.ts";
|
import type { User } from "./context.ts";
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||||
import { composeNav, type NavNode, type NavOverride } from "./nav.ts";
|
import { composeNav, type NavNode, type NavOverride } from "./nav.ts";
|
||||||
|
import type { Plugin } from "./plugin.ts";
|
||||||
import { parseListQuery } from "./list-query.ts";
|
import { parseListQuery } from "./list-query.ts";
|
||||||
import { paginate } from "./paginate.ts";
|
import { paginate } from "./paginate.ts";
|
||||||
import { buildShellContext } from "./shell-context.ts";
|
import { buildShellContext } from "./shell-context.ts";
|
||||||
@@ -80,7 +81,7 @@ function href(state: State, overrides: Partial<State> = {}): string {
|
|||||||
return qs ? `?${qs}` : "?";
|
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 query = parseListQuery(url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
const status = query.filters.status?.[0] ?? "all";
|
const status = query.filters.status?.[0] ?? "all";
|
||||||
const team = query.filters.team?.[0] ?? "";
|
const team = query.filters.team?.[0] ?? "";
|
||||||
@@ -105,7 +106,7 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
filterBar: filterBar(state),
|
filterBar: filterBar(state),
|
||||||
nav: nav(roles, menu.override),
|
nav: nav(roles, menu.override, plugins),
|
||||||
pagination: pagination(state, page),
|
pagination: pagination(state, page),
|
||||||
shell: buildShellContext({
|
shell: buildShellContext({
|
||||||
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
|
breadcrumbs: [{ href: "?", label: "Directory" }, { label: "People" }],
|
||||||
@@ -120,7 +121,11 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
|
|||||||
|
|
||||||
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
||||||
|
|
||||||
function nav(roles: string[], override: NavOverride): NavNode[] {
|
// Sidebar: the demo "Directory" fragment, then each discovered plugin's own nav fragment (so a
|
||||||
|
// plugin is reachable from "/"; gated nodes stay invisible to non-admins), then the gated admin
|
||||||
|
// section. composeNav applies the central override + per-user role filter.
|
||||||
|
function nav(roles: string[], override: NavOverride, plugins: Plugin[]): NavNode[] {
|
||||||
|
const pluginFragments = plugins.filter((p) => p.nav?.length).map((p) => p.nav as NavNode[]);
|
||||||
return composeNav([[
|
return composeNav([[
|
||||||
{ count: PEOPLE.length, current: true, href: "/", icon: "i-users", id: "people", label: "People" },
|
{ count: PEOPLE.length, current: true, href: "/", icon: "i-users", id: "people", label: "People" },
|
||||||
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
|
{ 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" },
|
{ href: "#exports", id: "exports", label: "Exports" },
|
||||||
], icon: "i-chart", id: "reports", label: "Reports", open: true },
|
], icon: "i-chart", id: "reports", label: "Reports", open: true },
|
||||||
{ href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" },
|
{ href: "#settings", icon: "i-gear", id: "settings", label: "Settings", permission: "admin" },
|
||||||
|
], ...pluginFragments, [
|
||||||
adminSection(), // built-in Users/Groups/Roles screens; gated → invisible to non-admins
|
adminSection(), // built-in Users/Groups/Roles screens; gated → invisible to non-admins
|
||||||
]], override, roles);
|
]], override, roles);
|
||||||
}
|
}
|
||||||
|
|||||||
2
todo.md
2
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.
|
- [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)
|
## 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.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] 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.
|
||||||
|
|||||||
@@ -24,5 +24,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["config", "src"]
|
"include": ["config", "plugins", "src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user