From 2eb5b84ccfd8cca44d70dafd92a1435f193ecd5d Mon Sep 17 00:00:00 2001 From: lilleman Date: Sat, 20 Jun 2026 17:18:30 +0200 Subject: [PATCH] =?UTF-8?q?=C2=A710=20gate=20the=20dashboard=20+=20make=20?= =?UTF-8?q?"/"=20replaceable=20by=20a=20plugin=20(todo=20=C2=A710);=20"/"?= =?UTF-8?q?=20is=20now=20gated=20to=20a=20signed-in=20session=20(anonymous?= =?UTF-8?q?=20=E2=86=92=20/login=20via=20loginRedirect,=20query=20preserve?= =?UTF-8?q?d=20as=20return=5Fto)=20and=20fully=20replaceable=20via=20a=20n?= =?UTF-8?q?ew=20optional=20home=3F:=20RouteHandler=20on=20PluginManifest?= =?UTF-8?q?=20=E2=80=94=20a=20handler=20with=20the=20same=20signature=20as?= =?UTF-8?q?=20any=20route=20(the=20most=20ergonomic=20shape).=20The=20app.?= =?UTF-8?q?ts=20"/"=20branch=20gates=20first,=20then=20renders=20the=20sin?= =?UTF-8?q?gle=20home=20plugin's=20handler=20against=20its=20own=20views/?= =?UTF-8?q?=20with=20the=20native=20shell=20via=20ctx.chrome=20(HEAD=20/?= =?UTF-8?q?=20void-return=20/=20response-hook=20parity=20with=20a=20plugin?= =?UTF-8?q?=20route),=20else=20the=20built-in=20mock-data=20People=20list.?= =?UTF-8?q?=20home=20mounts=20at=20the=20root=20above=20the=20/=20name?= =?UTF-8?q?space,=20so=20it=20can't=20shadow=20or=20be=20shadowed=20by=20a?= =?UTF-8?q?=20built-in=20route.=20Single-slot=20+=20loud:=20findConflicts?= =?UTF-8?q?=20errors=20on=20>1=20home=20(new=20"home"=20kind),=20discovery?= =?UTF-8?q?=20rejects=20a=20non-function=20home=20=E2=80=94=20never=20last?= =?UTF-8?q?-write-wins.=20Tests-first=20(338=20=E2=86=92=20344=20units):?= =?UTF-8?q?=20app.test.ts=20gate=20+=20home-override;=20plugin.test.ts=20h?= =?UTF-8?q?ome=20conflict;=20discovery.test.ts=20home=20validation.=20Docs?= =?UTF-8?q?:=20plugin-contract.md=20(manifest=20table=20+=20"The=20dashboa?= =?UTF-8?q?rd=20(home)"=20section=20+=20conflict=20row),=20README.=20E2E:?= =?UTF-8?q?=20visual.spec=20plants=20a=20dev-signed=20session=20(the=20ano?= =?UTF-8?q?nymous=20plugin-gate=20probe=20uses=20the=20cookie-free=20reque?= =?UTF-8?q?st=20fixture);=20all=20e2e=20web/gateway=20healthchecks=20repoi?= =?UTF-8?q?nted=20from=20the=20gated=20"/"=20to=20/public/css/styles.css.?= =?UTF-8?q?=20stability-reviewer:=20APPROVE,=20no=20Critical/High/Medium.?= =?UTF-8?q?=20typecheck=20+=20344=20units=20+=20visual(9)=20+=20full-flow(?= =?UTF-8?q?7)=20E2E=20green.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++- compose.e2e-auth.yml | 2 +- compose.e2e-full.yml | 4 +- compose.e2e-oauth.yml | 2 +- compose.e2e.yml | 5 +- docs/plugin-contract.md | 29 +++++++++++ e2e/visual.spec.ts | 32 +++++++++++-- src/app.test.ts | 103 +++++++++++++++++++++++++++++----------- src/app.ts | 19 +++++++- src/discovery.test.ts | 9 ++++ src/discovery.ts | 2 + src/plugin.test.ts | 8 ++++ src/plugin.ts | 10 +++- todo.md | 2 +- 14 files changed, 192 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 33163e4..26ec87a 100644 --- a/README.md +++ b/README.md @@ -361,6 +361,10 @@ hooks, and the dev/test story — is **[docs/plugin-contract.md](docs/plugin-con a CSRF-guarded form forwarding writes upstream, and permission-gated nav. Copy it and adapt. The sketch below is the shape. +The landing page `/` (the **dashboard**) is gated to a signed-in session; a plugin can **fully +replace** the built-in default by exporting a `home` handler in its manifest (one plugin may own it). +See the contract's [dashboard section](docs/plugin-contract.md#the-dashboard-home). + ``` plugins/scheduling/ # folder name = the plugin id; mounted at /scheduling plugin.ts # default export: the typed manifest (see below) @@ -741,7 +745,7 @@ src/logger.ts createLogger()/requestLogger() + the ambient request log (r src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms) src/context.ts RequestContext handed to handlers + buildContext() src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot -src/dashboard.ts buildDashboardModel(): the home "/" People list view model (mock data, wires the §1 helpers) +src/dashboard.ts buildDashboardModel(): the built-in "/" People list view model (mock data, wires the §1 helpers); "/" is gated to a session and replaceable by a plugin `home` handler (§10) src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded diff --git a/compose.e2e-auth.yml b/compose.e2e-auth.yml index f54b061..0f78e68 100644 --- a/compose.e2e-auth.yml +++ b/compose.e2e-auth.yml @@ -24,7 +24,7 @@ services: REQUIRE_SECURE_SECRETS: "false" SECURE_COOKIES: "false" healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"] interval: 2s timeout: 4s retries: 30 diff --git a/compose.e2e-full.yml b/compose.e2e-full.yml index 033e99b..e07847b 100644 --- a/compose.e2e-full.yml +++ b/compose.e2e-full.yml @@ -23,7 +23,7 @@ services: REQUIRE_SECURE_SECRETS: "false" SECURE_COOKIES: "false" # the browser hits the gateway over http — Secure cookies wouldn't be stored healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"] interval: 2s timeout: 4s retries: 30 @@ -79,7 +79,7 @@ services: volumes: - ./e2e/proxy.mjs:/proxy.mjs:ro healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/"] + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/public/css/styles.css"] interval: 2s timeout: 4s retries: 30 diff --git a/compose.e2e-oauth.yml b/compose.e2e-oauth.yml index d52691c..f5d75a7 100644 --- a/compose.e2e-oauth.yml +++ b/compose.e2e-oauth.yml @@ -13,7 +13,7 @@ services: REQUIRE_SECURE_SECRETS: "false" SECURE_COOKIES: "false" healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"] interval: 2s timeout: 4s retries: 30 diff --git a/compose.e2e.yml b/compose.e2e.yml index 37db043..be9cf15 100644 --- a/compose.e2e.yml +++ b/compose.e2e.yml @@ -15,7 +15,7 @@ services: REQUIRE_SECURE_SECRETS: "false" SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored healthcheck: - test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"] interval: 2s timeout: 4s retries: 15 @@ -35,4 +35,7 @@ services: # The mockups + their stylesheet, kept as siblings so file:// ../public/css resolves. - ./html-css-foundation:/repo/html-css-foundation:ro - ./public:/repo/public:ro + # The committed dev tokenizer key — the spec signs a session JWT with it so the gated + # dashboard (§10) renders; web verifies it with the same key (the file it mounts read-only). + - ./ory/kratos/tokenizer/jwks.json:/repo/jwks.json:ro - ./e2e/artifacts:/e2e/artifacts diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index 2e820d5..bb82197 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -98,6 +98,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder | Field | Required | Notes | | --- | --- | --- | | `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). | +| `home` | no | A `RouteHandler` that owns the dashboard `/` (the post-login landing page). At most one plugin may declare it. See [The dashboard](#the-dashboard-home). | | `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 docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). | | `routes` | no | See [Routes & handlers](#routes--handlers). | @@ -175,6 +176,33 @@ safety of the data it renders**: return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } }; ``` +## The dashboard ("home") + +`/` is the **post-login landing page**. The host gates it to a **signed-in session** (an anonymous +visitor is redirected to `/login`) and, by default, renders a built-in mock-data dashboard. A plugin +**fully replaces** it by exporting a `home` handler: + +```ts +import { definePlugin } from "../../src/plugin-api.ts"; +import { dashboard } from "./dashboard.ts"; + +export default definePlugin({ + apiVersion: "1.0.0", + home: dashboard, // owns "/" — the post-login landing page +}); +``` + +`home` is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) +and returns a `RouteResult`, typically a `view` rendered from the plugin's own `views/` against the +native app shell (`ctx.chrome`), exactly as a route handler does. The host enforces the session gate +first, so `ctx.user` is non-null; branch on `ctx.roles` *inside* to tailor the page per role. Don't +gate `home` itself behind a single permission — there's no second dashboard to fall back to, so a +user lacking it would land on a 403 instead of a home page. (`GET /` also answers `HEAD`.) + +Only **one** plugin may own the dashboard: two declaring `home` is a boot-stopping conflict +([below](#conflict-rules)), never last-write-wins. The plugin needs no `routes` entry for `/` — the +host mounts `home` at the root, above the `/` route namespace. + ## RequestContext Every handler receives one argument, the `RequestContext` (`src/context.ts`), built once per @@ -278,6 +306,7 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er | `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. | | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/` prefix is unique), so this catches a plugin duplicating one of its own. | | `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. | +| `home` | error | More than one plugin declares `home`. The dashboard `/` is a single slot, so only one may own it ([The dashboard](#the-dashboard-home)). | | `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `:` if unintended. | There is **no separate `basePath` rule**: the mount path is the derived `/`, so its diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index 2aa0cdc..ca199fd 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -1,3 +1,5 @@ +import { createPrivateKey, sign } from "node:crypto"; +import { readFileSync } from "node:fs"; import { mkdir } from "node:fs/promises"; import { expect, test, type Page } from "@playwright/test"; @@ -6,12 +8,32 @@ const MOCKUP = "file:///repo/html-css-foundation"; const APP_SHELL = `${MOCKUP}/App%20Shell.html`; const AUTH = `${MOCKUP}/Auth.html`; const SHOTS = "artifacts/screenshots"; +const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000"; +const SESSION_COOKIE = "plainpages_jwt"; // src/login.ts — web verifies it against the committed dev JWKS const shot = (page: Page, name: string): Promise => page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` }); +// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the +// gated dashboard (§10) renders for a "signed-in" user without standing up Ory — web verifies it +// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT. +function devSession(roles: string[] = []): string { + const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0]; + const key = createPrivateKey({ format: "jwk", key: jwk }); + const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url"); + const now = Math.floor(Date.now() / 1000); + const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, roles, sub: "visual-demo" })}`; + return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`; +} + test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); }); +// The dashboard is gated (§10): a page navigation needs a session. Plant one per test — a plain +// member (no roles) so the gated scheduling/admin nav stays filtered out, matching the mockup. +test.beforeEach(async ({ context }) => { + await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); +}); + test("captures live pages + reference mockups for side-by-side review", async ({ page }) => { await page.goto("/"); await expect(page.locator(".sidebar")).toBeVisible(); @@ -125,13 +147,15 @@ 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 is bounced to sign in (and never sees it in the nav). // The authenticated list/form flow is the §8 full E2E (full-flow.spec). Side-effect-free. -test("the reference plugin is permission-gated: anonymous → redirect to /login, hidden from the dashboard nav", async ({ page }) => { - // Don't follow the redirect — this Ory-free suite has no /login handler; assert the gate's 303 itself. - // The gate preserves the requested page as return_to (§9), so login can land back there. - const res = await page.request.get("/scheduling/shifts", { maxRedirects: 0 }); +test("the reference plugin is permission-gated: anonymous → redirect to /login, hidden from the dashboard nav", async ({ page, request }) => { + // `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so this + // probe is genuinely anonymous. Don't follow the redirect (this Ory-free suite has no /login + // handler); assert the gate's 303 itself, with the requested page preserved as return_to (§9). + const res = await request.get("/scheduling/shifts", { maxRedirects: 0 }); expect(res.status()).toBe(303); expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts"); + // The signed-in member (no scheduling role) sees the dashboard, but the gated leaf is filtered out. await page.goto("/"); await expect(page.locator(".sidebar")).toContainText("People"); // dashboard nav renders await expect(page.locator(".sidebar")).not.toContainText("Scheduling"); // gated leaf filtered out diff --git a/src/app.test.ts b/src/app.test.ts index 91c025c..119d30f 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -23,7 +23,21 @@ import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts"; const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views"); -const server = createApp(); +// A session JWT signed with a throwaway test key — the §4 verify path. Wired into the shared +// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the +// gated routes need one (§10). `staticJwks([ecJwk])` is the matching verify side. +const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); +const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" }; +const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url"); +function mintJwt(payload: Record): string { + const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`; + return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`; +} +// A session cookie carrying `roles`, valid for 10 min — the auth most tests need to reach a gated page. +const session = (roles: string[] = []): string => + `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles, sub: "u1" })}`; + +const server = createApp({ jwks: staticJwks([ecJwk]) }); let base = ""; before(async () => { @@ -34,7 +48,8 @@ before(async () => { after(() => server.close()); test("serves the home page: the app-shell People dashboard, filterable via the URL", async () => { - const res = await fetch(base + "/"); + // The dashboard is gated to a signed-in user (§10), so present a session. + const res = await fetch(base + "/", { headers: { cookie: session() } }); assert.equal(res.status, 200); assert.match(res.headers.get("content-type") ?? "", /text\/html/); const html = await res.text(); @@ -54,15 +69,52 @@ test("serves the home page: the app-shell People dashboard, filterable via the U assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`)); // A search query filters server-side: a no-match query drops every row. - const empty = await fetch(base + "/?q=zzz-no-such-person"); + const empty = await fetch(base + "/?q=zzz-no-such-person", { headers: { cookie: session() } }); assert.doesNotMatch(await empty.text(), /Avery Kline/); }); -test("renders branding from the menu config into the shell: logo + default theme", async (t) => { - const app = createApp({ menu: { branding: { logo: "/public/brand/logo.svg", name: "Acme Ops", theme: "dark" }, override: {} } }); +test("the dashboard is gated (§10): an anonymous visitor is bounced to sign in, not shown the page", async () => { + const res = await fetch(base + "/", { redirect: "manual" }); + assert.equal(res.status, 303); + assert.equal(res.headers.get("location"), "/login"); +}); + +test("a `home` plugin fully replaces the dashboard, rendered in the native shell from ctx.chrome; still gated (§10)", async (t) => { + const dir = mkdtempSync(join(tmpdir(), "pp-home-")); + mkdirSync(join(dir, "portal", "views"), { recursive: true }); + // The home view renders the native app shell from ctx.chrome — the blessed plugin ergonomics: + // its own title/body, the global menu (chrome.nav), the signed-in user, the Sign-out CSRF token. + writeFileSync(join(dir, "portal", "views", "home.ejs"), + `<%- include("partials/shell", { body: "

Welcome " + user.email + "

", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`); + t.after(() => rmSync(dir, { force: true, recursive: true })); + const portal: Plugin = { + apiVersion: "1.0.0", + home: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.user }, view: "home" }), + id: "portal", + }; + const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [portal], pluginsDir: dir }); await new Promise((r) => app.listen(0, r)); t.after(() => app.close()); - const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/`)).text(); + const url = `http://localhost:${(app.address() as AddressInfo).port}`; + + // Gate still applies — the home plugin doesn't open the page up. + assert.equal((await fetch(url + "/", { redirect: "manual" })).status, 303); + + // Signed in: the plugin's dashboard renders, fully replacing the built-in People list. + const page = await fetch(url + "/", { headers: { cookie: session() } }); + assert.equal(page.status, 200); + const html = await page.text(); + assert.match(html, /

My Portal<\/h1>/); // the plugin's own title in the native shell + assert.match(html, /Welcome a@b\.c/); // its handler rendered, with ctx.user + assert.match(html, /