§10 gate the dashboard + make "/" replaceable by a plugin (todo §10); "/" is now gated to a signed-in session (anonymous → /login via loginRedirect, query preserved as return_to) and fully replaceable via a new optional home?: RouteHandler on PluginManifest — a handler with the same signature as any route (the most ergonomic shape). The app.ts "/" branch gates first, then renders the single home plugin's handler against its own views/ with the native shell via ctx.chrome (HEAD / void-return / response-hook parity with a plugin route), else the built-in mock-data People list. home mounts at the root above the /<id> namespace, so it can't shadow or be shadowed by a built-in route. Single-slot + loud: findConflicts errors on >1 home (new "home" kind), discovery rejects a non-function home — never last-write-wins. Tests-first (338 → 344 units): app.test.ts gate + home-override; plugin.test.ts home conflict; discovery.test.ts home validation. Docs: plugin-contract.md (manifest table + "The dashboard (home)" section + conflict row), README. E2E: visual.spec plants a dev-signed session (the anonymous plugin-gate probe uses the cookie-free request fixture); all e2e web/gateway healthchecks repointed from the gated "/" to /public/css/styles.css. stability-reviewer: APPROVE, no Critical/High/Medium. typecheck + 344 units + visual(9) + full-flow(7) E2E green.

This commit is contained in:
2026-06-20 17:18:30 +02:00
parent df53106a5a
commit 2eb5b84ccf
14 changed files with 192 additions and 41 deletions

View File

@@ -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 a CSRF-guarded form forwarding writes upstream, and permission-gated nav. Copy it and
adapt. The sketch below is the shape. 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 plugins/scheduling/ # folder name = the plugin id; mounted at /scheduling
plugin.ts # default export: the typed manifest (see below) 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/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/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot 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-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-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 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

View File

@@ -24,7 +24,7 @@ services:
REQUIRE_SECURE_SECRETS: "false" REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" SECURE_COOKIES: "false"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
interval: 2s interval: 2s
timeout: 4s timeout: 4s
retries: 30 retries: 30

View File

@@ -23,7 +23,7 @@ services:
REQUIRE_SECURE_SECRETS: "false" REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # the browser hits the gateway over http — Secure cookies wouldn't be stored SECURE_COOKIES: "false" # the browser hits the gateway over http — Secure cookies wouldn't be stored
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
interval: 2s interval: 2s
timeout: 4s timeout: 4s
retries: 30 retries: 30
@@ -79,7 +79,7 @@ services:
volumes: volumes:
- ./e2e/proxy.mjs:/proxy.mjs:ro - ./e2e/proxy.mjs:/proxy.mjs:ro
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/"] test: ["CMD", "wget", "-q", "-O", "-", "http://localhost/public/css/styles.css"]
interval: 2s interval: 2s
timeout: 4s timeout: 4s
retries: 30 retries: 30

View File

@@ -13,7 +13,7 @@ services:
REQUIRE_SECURE_SECRETS: "false" REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" SECURE_COOKIES: "false"
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
interval: 2s interval: 2s
timeout: 4s timeout: 4s
retries: 30 retries: 30

View File

@@ -15,7 +15,7 @@ services:
REQUIRE_SECURE_SECRETS: "false" REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored SECURE_COOKIES: "false" # the suite hits web over http — Secure cookies wouldn't be stored
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/"] test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:3000/public/css/styles.css"]
interval: 2s interval: 2s
timeout: 4s timeout: 4s
retries: 15 retries: 15
@@ -35,4 +35,7 @@ services:
# The mockups + their stylesheet, kept as siblings so file:// ../public/css resolves. # The mockups + their stylesheet, kept as siblings so file:// ../public/css resolves.
- ./html-css-foundation:/repo/html-css-foundation:ro - ./html-css-foundation:/repo/html-css-foundation:ro
- ./public:/repo/public: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 - ./e2e/artifacts:/e2e/artifacts

View File

@@ -98,6 +98,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
| Field | Required | Notes | | Field | Required | Notes |
| --- | --- | --- | | --- | --- | --- |
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). | | `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. | | `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)). | | `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). | | `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) })) } }; 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 `/<id>` route namespace.
## RequestContext ## RequestContext
Every handler receives one argument, the `RequestContext` (`src/context.ts`), built once per 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. | | `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 `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. | | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` 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. | | `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 `<id>:<action>` if unintended. | | `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. |
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its

View File

@@ -1,3 +1,5 @@
import { createPrivateKey, sign } from "node:crypto";
import { readFileSync } from "node:fs";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { expect, test, type Page } from "@playwright/test"; 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 APP_SHELL = `${MOCKUP}/App%20Shell.html`;
const AUTH = `${MOCKUP}/Auth.html`; const AUTH = `${MOCKUP}/Auth.html`;
const SHOTS = "artifacts/screenshots"; 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<Buffer> => const shot = (page: Page, name: string): Promise<Buffer> =>
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` }); 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 }); }); 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 }) => { test("captures live pages + reference mockups for side-by-side review", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect(page.locator(".sidebar")).toBeVisible(); 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 // 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). // 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. // 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 }) => { test("the reference plugin is permission-gated: anonymous → redirect to /login, hidden from the dashboard nav", async ({ page, request }) => {
// Don't follow the redirect — this Ory-free suite has no /login handler; assert the gate's 303 itself. // `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so this
// The gate preserves the requested page as return_to (§9), so login can land back there. // probe is genuinely anonymous. Don't follow the redirect (this Ory-free suite has no /login
const res = await page.request.get("/scheduling/shifts", { maxRedirects: 0 }); // 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.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts"); 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 page.goto("/");
await expect(page.locator(".sidebar")).toContainText("People"); // dashboard nav renders await expect(page.locator(".sidebar")).toContainText("People"); // dashboard nav renders
await expect(page.locator(".sidebar")).not.toContainText("Scheduling"); // gated leaf filtered out await expect(page.locator(".sidebar")).not.toContainText("Scheduling"); // gated leaf filtered out

View File

@@ -23,7 +23,21 @@ import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views"); 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, unknown>): 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 = ""; let base = "";
before(async () => { before(async () => {
@@ -34,7 +48,8 @@ before(async () => {
after(() => server.close()); after(() => server.close());
test("serves the home page: the app-shell People dashboard, filterable via the URL", async () => { 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.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/); assert.match(res.headers.get("content-type") ?? "", /text\/html/);
const html = await res.text(); 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, "\\.")}"`)); assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`));
// A search query filters server-side: a no-match query drops every row. // 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/); assert.doesNotMatch(await empty.text(), /Avery Kline/);
}); });
test("renders branding from the menu config into the shell: logo + default theme", async (t) => { test("the dashboard is gated (§10): an anonymous visitor is bounced to sign in, not shown the page", async () => {
const app = createApp({ menu: { branding: { logo: "/public/brand/logo.svg", name: "Acme Ops", theme: "dark" }, override: {} } }); 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: "<p>Welcome " + user.email + "</p>", 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<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close()); 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, /<h1 class="page-title">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, /<aside class="sidebar"/); // composed chrome (global nav) is available
assert.doesNotMatch(html, /Avery Kline/); // the built-in mock People list is gone — fully replaced
});
test("renders branding from the menu config into the shell: logo + default theme", async (t) => {
const app = createApp({ jwks: staticJwks([ecJwk]), menu: { branding: { logo: "/public/brand/logo.svg", name: "Acme Ops", theme: "dark" }, override: {} } });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/`, { headers: { cookie: session() } })).text();
assert.match(html, /<img class="brand-logo" src="\/public\/brand\/logo\.svg"/); assert.match(html, /<img class="brand-logo" src="\/public\/brand\/logo\.svg"/);
assert.match(html, /Acme Ops/); assert.match(html, /Acme Ops/);
@@ -71,10 +123,10 @@ test("renders branding from the menu config into the shell: logo + default theme
test("emits a structured access-log line per request (the injected §9 logger)", async (t) => { test("emits a structured access-log line per request (the injected §9 logger)", async (t) => {
const lines: string[] = []; const lines: string[] = [];
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) }); const app = createApp({ jwks: staticJwks([ecJwk]), log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close()); t.after(() => app.close());
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/?q=zz`); const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/?q=zz`, { headers: { cookie: session() } });
assert.equal(res.status, 200); assert.equal(res.status, 200);
await res.text(); // consume the body so the connection closes (the access line emits on close) await res.text(); // consume the body so the connection closes (the access line emits on close)
@@ -199,7 +251,7 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES (
// Default app (secureCookies off): a page and a static asset both carry the hardening headers, // Default app (secureCookies off): a page and a static asset both carry the hardening headers,
// proving they're set once up front and survive each writeHead (the html + static paths merge). // proving they're set once up front and survive each writeHead (the html + static paths merge).
for (const path of ["/", "/public/css/styles.css"]) { for (const path of ["/", "/public/css/styles.css"]) {
const res = await fetch(base + path); const res = await fetch(base + path, { headers: { cookie: session() } });
assert.equal(res.headers.get("x-content-type-options"), "nosniff", path); assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
assert.equal(res.headers.get("x-frame-options"), "DENY", path); assert.equal(res.headers.get("x-frame-options"), "DENY", path);
assert.match(res.headers.get("content-security-policy") ?? "", /default-src 'self'/, path); assert.match(res.headers.get("content-security-policy") ?? "", /default-src 'self'/, path);
@@ -207,21 +259,21 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES (
} }
// A https deployment (SECURE_COOKIES=true) adds HSTS. // A https deployment (SECURE_COOKIES=true) adds HSTS.
const secure = createApp({ secureCookies: true }); const secure = createApp({ jwks: staticJwks([ecJwk]), secureCookies: true });
await new Promise<void>((r) => secure.listen(0, r)); await new Promise<void>((r) => secure.listen(0, r));
t.after(() => secure.close()); t.after(() => secure.close());
const res = await fetch(`http://localhost:${(secure.address() as AddressInfo).port}/`); const res = await fetch(`http://localhost:${(secure.address() as AddressInfo).port}/`, { headers: { cookie: session() } });
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/); assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
}); });
// Production caches compiled templates; rendering must stay correct across repeated requests. // Production caches compiled templates; rendering must stay correct across repeated requests.
test("renders correctly with template caching enabled", async () => { test("renders correctly with template caching enabled", async () => {
const app = createApp({ cache: true }); const app = createApp({ cache: true, jwks: staticJwks([ecJwk]) });
try { try {
await new Promise<void>((resolve) => app.listen(0, resolve)); await new Promise<void>((resolve) => app.listen(0, resolve));
const url = `http://localhost:${(app.address() as AddressInfo).port}/`; const url = `http://localhost:${(app.address() as AddressInfo).port}/`;
for (let i = 0; i < 2; i++) { for (let i = 0; i < 2; i++) {
const res = await fetch(url); const res = await fetch(url, { headers: { cookie: session() } });
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.match(await res.text(), /Plainpages/); assert.match(await res.text(), /Plainpages/);
} }
@@ -241,10 +293,11 @@ test("renders the 500 HTML page when a handler throws", async () => {
const dir = mkdtempSync(join(tmpdir(), "pp-views-")); const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>");
cpSync(join(viewsDir, "500.ejs"), join(dir, "500.ejs")); cpSync(join(viewsDir, "500.ejs"), join(dir, "500.ejs"));
const app = createApp({ viewsDir: dir }); const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
try { try {
await new Promise<void>((resolve) => app.listen(0, resolve)); await new Promise<void>((resolve) => app.listen(0, resolve));
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/`); // A session reaches the (throwing) index render; the gate would otherwise bounce anonymous to /login.
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/`, { headers: { cookie: session() } });
assert.equal(res.status, 500); assert.equal(res.status, 500);
assert.match(res.headers.get("content-type") ?? "", /text\/html/); assert.match(res.headers.get("content-type") ?? "", /text\/html/);
assert.match(await res.text(), /500/); assert.match(await res.text(), /500/);
@@ -378,14 +431,7 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
}); });
// 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" }); // The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
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, unknown>): 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 }))}`;
}
test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => { test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => {
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] }); const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
@@ -406,10 +452,13 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret"); assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303); assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
// The home menu wires in the permission-gated Admin section: an admin's roles surface the links. // The dashboard wires in the permission-gated Admin section: an admin's roles surface the links;
const home = (cookie?: string) => fetch(url + "/", cookie ? { headers: { cookie } } : {}); // anonymous is bounced to sign in before any page renders (§10 gate).
assert.match(await (await home(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}`)).text(), /href="\/admin\/users"/); const admin = await fetch(url + "/", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
assert.doesNotMatch(await (await home()).text(), /href="\/admin\/users"/); // anonymous → no admin section assert.match(await admin.text(), /href="\/admin\/users"/);
const anonHome = await fetch(url + "/", { redirect: "manual" });
assert.equal(anonHome.status, 303);
assert.equal(anonHome.headers.get("location"), "/login");
}); });
test("revocation denylist (§9): a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => { test("revocation denylist (§9): a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {

View File

@@ -29,7 +29,7 @@ import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from
import { resolveLoginChallenge } from "./oauth-login.ts"; import { resolveLoginChallenge } from "./oauth-login.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts"; import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts"; import type { Plugin, RouteHandler, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
import { securityHeaders } from "./security-headers.ts"; import { securityHeaders } from "./security-headers.ts";
import { localPath } from "./safe-url.ts"; import { localPath } from "./safe-url.ts";
@@ -79,6 +79,9 @@ export function createApp(options: AppOptions = {}): Server {
const menu = options.menu ?? DEFAULT_MENU; const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? []; const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id)); const pluginIds = new Set(plugins.map((p) => p.id));
// A plugin may fully replace the dashboard "/" by declaring `home` (§10). Discovery's findConflicts
// guarantees at most one, so `find` is unambiguous; the predicate narrows `home` to defined.
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free). // Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest); const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse); const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
@@ -430,9 +433,21 @@ export function createApp(options: AppOptions = {}): Server {
} }
if (pathname === "/" && (method === "GET" || method === "HEAD")) { if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// Roles from the verified JWT (anonymous ⇒ []); branding/override come from config/menu.ts. // The dashboard is the post-login landing page, gated to a signed-in user (§10): anonymous
// bounces to sign in (loginRedirect yields a bare /login for "/").
if (!user) { res.writeHead(303, { location: loginRedirect(ctx) }).end(); return; }
// 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 }));
// A plugin may fully own the dashboard (§10): render its handler against its own views, native
// shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
if (homePlugin) {
const homeCtx = buildContext(req, res, { chrome: chrome(), log: reqLog, user, verifyCsrf });
const result = (await homePlugin.home(homeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result);
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
return;
}
// Roles from the verified JWT; branding/override come from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token, user, plugins) })); sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token, user, plugins) }));
return; return;
} }

View File

@@ -47,7 +47,9 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s }, { name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },
{ name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s }, { name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s },
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s }, { name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s },
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ }, { name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "two plugins claim the dashboard home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
]; ];
for (const c of badCases) { for (const c of badCases) {
@@ -56,6 +58,13 @@ for (const c of badCases) {
}); });
} }
test("a plugin may declare `home` (a function) to own the dashboard (§10)", async (t) => {
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }) };` });
const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1);
assert.equal(typeof plugins[0]?.home, "function");
});
test("a shared permission token only warns — both plugins still load", async (t) => { test("a shared permission token only warns — both plugins still load", async (t) => {
const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`; const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm }); const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm });

View File

@@ -88,6 +88,8 @@ function shapeError(manifest: PluginManifest): string | null {
for (const field of ["nav", "permissions", "routes"] as const) { for (const field of ["nav", "permissions", "routes"] as const) {
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`; if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
} }
// `home` (the §10 dashboard override) is a route handler; the host calls it, so a non-function fails loud.
if (manifest.home !== undefined && typeof manifest.home !== "function") return `"home" must be a function (a route handler)`;
return null; return null;
} }

View File

@@ -99,3 +99,11 @@ test("findConflicts: duplicate nav id is an error, a shared permission token onl
]); ]);
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn")); assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn"));
}); });
test("findConflicts: only one plugin may claim the dashboard (`home`) — two is a loud error (§10)", () => {
const home = () => ({ html: "dash" });
const dup = findConflicts([p({ id: "a", home }), p({ id: "b", home })]);
assert.ok(dup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
// One home (or none) is fine.
assert.deepEqual(findConflicts([p({ id: "a", home }), p({ id: "b" })]).filter((c) => c.kind === "home"), []);
});

View File

@@ -50,6 +50,10 @@ export interface PluginHooks {
// host derives them from the folder name at discovery (see Plugin). // host derives them from the folder name at discovery (see Plugin).
export interface PluginManifest { export interface PluginManifest {
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs) apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
// Take over the dashboard "/" — the post-login landing page (§10). A handler like any route's,
// gated by the host to a signed-in session (anonymous → /login); render its own view via ctx.chrome.
// At most one plugin may declare it (findConflicts → error, never last-write-wins).
home?: RouteHandler;
hooks?: PluginHooks; hooks?: PluginHooks;
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/icons.ts), node ids must be globally unique nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/icons.ts), node ids must be globally unique
permissions?: PermissionDecl[]; permissions?: PermissionDecl[];
@@ -134,7 +138,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
} }
export interface PluginConflict { export interface PluginConflict {
kind: "id" | "nav-id" | "permission" | "route"; kind: "home" | "id" | "nav-id" | "permission" | "route";
level: "error" | "warn"; level: "error" | "warn";
message: string; message: string;
plugins: string[]; // unique ids involved plugins: string[]; // unique ids involved
@@ -153,6 +157,10 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] }); if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
} }
// The dashboard "/" is a single slot (§10): two plugins claiming `home` is a loud error, not a race.
const homeOwners = plugins.filter((plugin) => plugin.home).map((plugin) => plugin.id);
if (homeOwners.length > 1) out.push({ kind: "home", level: "error", message: `${homeOwners.length} plugins claim the dashboard "home" (${homeOwners.join(", ")}); only one may`, plugins: uniq(homeOwners) });
collect(plugins, (plugin, push) => { collect(plugins, (plugin, push) => {
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`); for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
}).forEach((owners, key) => { }).forEach((owners, key) => {

View File

@@ -136,5 +136,5 @@ 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 §9 test accretion (`security-headers`/`denylist`/`logger`/`safe-url` units, `gen-jwks` `rotateJwks`, + the §9 additions across `config`/`app`/`jwt-middleware`/`context`/`plugin-api`/`guards`). The new unit files are one-concern-per-test matrices (logger severity/level/format/trace-continuation, denylist iat-cutoff/TTL-evict, safe-url scheme/host-relative, security-headers strict-CSP) and the per-field `config` toggles (`SERVICE_NAME`/`LOG_*`/`OTLP_*`/`REVOCATION_*`/`JWT_CLOCK_SKEW`/`ORY_TIMEOUT`) follow the file's existing per-field validation pattern — no fat, and force-merging distinct fields/surfaces would only hurt readability (the §3 "don't merge across distinct concerns" rule). **One genuine §9-era overlap:** `app.test.ts` carried two `/login?return_to=…` tests for the *same* surface — the §6 "bakes the return target into the Kratos flow init (OAuth bounce)" and the §9 "a first-party deep link is wrapped through `/auth/complete`; an absolute target passes through as-is". The §9 test subsumes the §6 one: its middle assertion already proves an absolute `/oauth2/login?login_challenge=` target is passed to `initBrowserFlow` **unchanged** (the exact §6 OAuth-bounce contract — and it's labeled as such in the test name + inline comment), plus the new host-relative-wrap + protocol-relative cases the §6 test never had. Removed the now-redundant standalone §6 test (zero coverage lost). Pure test refactor, no production code (per the §6/§7/§8 precedent, no stability reviewer). 339 → 338 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 §9 test accretion (`security-headers`/`denylist`/`logger`/`safe-url` units, `gen-jwks` `rotateJwks`, + the §9 additions across `config`/`app`/`jwt-middleware`/`context`/`plugin-api`/`guards`). The new unit files are one-concern-per-test matrices (logger severity/level/format/trace-continuation, denylist iat-cutoff/TTL-evict, safe-url scheme/host-relative, security-headers strict-CSP) and the per-field `config` toggles (`SERVICE_NAME`/`LOG_*`/`OTLP_*`/`REVOCATION_*`/`JWT_CLOCK_SKEW`/`ORY_TIMEOUT`) follow the file's existing per-field validation pattern — no fat, and force-merging distinct fields/surfaces would only hurt readability (the §3 "don't merge across distinct concerns" rule). **One genuine §9-era overlap:** `app.test.ts` carried two `/login?return_to=…` tests for the *same* surface — the §6 "bakes the return target into the Kratos flow init (OAuth bounce)" and the §9 "a first-party deep link is wrapped through `/auth/complete`; an absolute target passes through as-is". The §9 test subsumes the §6 one: its middle assertion already proves an absolute `/oauth2/login?login_challenge=` target is passed to `initBrowserFlow` **unchanged** (the exact §6 OAuth-bounce contract — and it's labeled as such in the test name + inline comment), plus the new host-relative-wrap + protocol-relative cases the §6 test never had. Removed the now-redundant standalone §6 test (zero coverage lost). Pure test refactor, no production code (per the §6/§7/§8 precedent, no stability reviewer). 339 → 338 units; typecheck + tests green.
## 10. User added stuff ## 10. User added stuff
- [ ] The dashboard, the first landing page after logging in, should be gated to only logged in users. It should also be replaceable fully from a plugin. It is important that the ergonomics for the plugin writer is great. - [x] The dashboard, the first landing page after logging in, should be gated to only logged in users. It should also be replaceable fully from a plugin. It is important that the ergonomics for the plugin writer is great.`/` is now **gated to a signed-in session** (anonymous → `/login` via `loginRedirect`, query preserved as `return_to`), and **fully replaceable by a plugin**: a new optional `home?: RouteHandler` on `PluginManifest` (`src/plugin.ts`) — the most ergonomic possible shape, a handler with the same signature as any route. The host (`app.ts` `/` branch) gates first, then renders the single home plugin's handler against its own `views/` with the native shell via `ctx.chrome` (same path/parity as a plugin route: HEAD, `void`-return, response hooks), else the built-in mock-data People list. Identity stays folder-derived; `home` mounts at the root above the `/<id>` namespace, so it can't shadow (or be shadowed by) a built-in route. **Single-slot, loud:** `findConflicts` errors when >1 plugin declares `home` (new `"home"` conflict kind), `discovery.shapeError` rejects a non-function `home` — never last-write-wins. Tests-first (344 units, was 338): `app.test.ts` gate (anon→303 /login) + home-override integration (plugin dashboard replaces the People list, still gated); `plugin.test.ts` home conflict; `discovery.test.ts` home-not-a-function + two-homes + valid-home-loads. Docs: `docs/plugin-contract.md` (manifest table + a "The dashboard (home)" section + conflict-rule row), README (Building-a-plugin note + Layout). **E2E:** the Ory-free `visual.spec.ts` now plants a dev-signed session (signs with the committed tokenizer key, bind-mounted into the runner; the anonymous plugin-gate probe uses the cookie-free `request` fixture); all five e2e web/gateway healthchecks repointed from the now-gated `/` to the auth-free `/public/css/styles.css`. stability-reviewer on the prod diff: **APPROVE, no Critical/High/Medium** (verified no shadowing either direction, gate↔re-mint ordering, HEAD/void/hook parity, open-redirect-safe). typecheck + 344 units + visual (9) + full-flow (7) E2E green, stacks torn down.
- [ ] Make some pages optionally available publicly. A plugin should be able to set the permissions of a page (including the menu option) to publicly available. - [ ] Make some pages optionally available publicly. A plugin should be able to set the permissions of a page (including the menu option) to publicly available.