diff --git a/README.md b/README.md index 82f4ad2..379aaf4 100644 --- a/README.md +++ b/README.md @@ -358,9 +358,9 @@ registration step, no central wiring. The full, authoritative API surface — manifest shape, handler/`RequestContext` contract, versioning, conflict rules, hooks, and the dev/test story — is **[docs/plugin-contract.md](docs/plugin-contract.md)** (`src/plugin.ts` holds the types). 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/`](plugins/scheduling/)** — a public overview page, a permission-gated +list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, and a mix of +public + role-gated nav. Copy it and adapt. The sketch below is the shape. There are two replaceable landing slots: `/` is a **public** front page (default: an intro with sign-in / register links) and `/dashboard` is the **gated** post-login app home (default: the People @@ -382,25 +382,29 @@ sync. The `id` and mount path are **derived from the folder name**, not declared ```ts import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs) -import { listShifts } from "./shifts.ts"; +import { listShifts, overview } from "./shifts.ts"; export default definePlugin({ apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see docs) // Nav fragment, composed into the global menu. Permission-gated: items the current user can't - // access are hidden. Arbitrary depth. `icon` is a Lucide icon by its sprite id (src/icons.ts). + // access are hidden. `public: true` shows an item to everyone (signed in or not). Arbitrary + // depth. `icon` is a Lucide icon by its sprite id (src/icons.ts). nav: [ { label: "Scheduling", icon: "i-cal", children: [ + { label: "Overview", href: "/scheduling", public: true }, // shown to everyone { label: "Shifts", href: "/scheduling/shifts", permission: "scheduling:read" }, ], }, ], // Route handlers, mounted under the plugin's path (/scheduling). `permission` is a coarse role - // (a JWT-claim check) enforced before the handler runs. + // (a JWT-claim check) enforced before the handler runs; `public: true` makes a page reachable by + // anyone (mutually exclusive with `permission`). routes: [ + { method: "GET", path: "/", public: true, handler: overview }, { method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts }, ], }); @@ -474,7 +478,11 @@ The menu is **driven entirely by config** and assembled from two sources: Every nav item may carry a `permission`; the rendered tree is **filtered per user** by reading the roles in the session JWT (no per-request authz call — see [Auth, sessions & permissions](#auth-sessions--permissions)), so the menu -only ever shows what that person can reach. The markup is the recursive, zero-JS +only ever shows what that person can reach. An item (or a whole page) may instead be +marked **`public: true`** to show it to **everyone, signed in or not** — the blessed, +explicit way to expose a public page and its menu entry (a no-permission item is already +public; `public` just says so on purpose, and is mutually exclusive with `permission`). +The markup is the recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static, counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar brand shows the configured logo (else a default mark), and the theme sets the theme-switch default. diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index a0936e5..dde3456 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -109,14 +109,16 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field ## Routes & handlers -A route is `{ method, path, permission?, handler }`. `path` is **relative to the plugin's mount -path `/`** (so `/shifts` in the `scheduling` plugin serves `/scheduling/shifts`); the host +A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's +mount path `/`** (so `/shifts` in the `scheduling` plugin serves `/scheduling/shifts`); the host matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the `permission` gate (a coarse JWT-claim check — see the README), and only then calls the handler with the [request context](#requestcontext). When the gate fails, an **anonymous** visitor is redirected to `/login` to sign in (same as the built-in admin screens); the requested page is preserved as `return_to`, so after signing in they land **back on the page they asked for**, not the -dashboard. A **signed-in** user who simply lacks the role gets the **403** page. +dashboard. A **signed-in** user who simply lacks the role gets the **403** page. A route marked +**`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu +items](#public-pages--menu-items)). `method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`. @@ -264,10 +266,24 @@ from the §4 JWT middleware and are `null`/`[]` until a session exists. A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/nav.ts`), which applies the central override and then **filters per user** by the roles in the session JWT — a -node shows iff it declares no `permission` or the user's roles include that token. Use arbitrary -depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a **Lucide -icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are -`ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there. +node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use +arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a +**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids +are `ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there. + +### Public pages & menu items + +A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**, +and the menu item shows for everyone. This is the same as omitting `permission` (a no-permission +route/node is already open) but stated outright, so "public" is a **deliberate choice, not the +accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring +both is contradictory and discovery refuses the plugin at boot. + +A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor +`ctx.user` is `null`, the shell shows a **Sign in** link in place of the profile/sign-out block, and +`ctx.roles` is empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling` +**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone, +while the actual shifts list stays behind `scheduling:read`. **A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles` include the token; those roles come from Keto at login, so an operator grants a token by writing the @@ -325,6 +341,10 @@ There is **no separate `basePath` rule**: the mount path is the derived `/`, uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns rather than aborts; everything else is an error an author fixes before the host will start. +Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a +non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that +sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)). + ## Hooks Optional, for reacting to system actions. A plugin's `hooks` may implement: diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index 64b2833..6ec72d1 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -154,19 +154,28 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to 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 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, 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). +// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is +// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated, +// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the §8 full +// E2E (full-flow.spec). Side-effect-free. +test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login (§10)", async ({ page, request }) => { + // `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these + // probes are genuinely anonymous. + // The public overview is reachable with no session (200), not bounced to sign in. + const pub = await request.get("/scheduling", { maxRedirects: 0 }); + expect(pub.status()).toBe(200); + expect(await pub.text()).toContain("Scheduling"); + + // The gated shifts list still bounces (don't follow — this Ory-free suite has no /login handler); + // assert the gate's 303 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. + // The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav, + // but the gated Shifts leaf is filtered out. await page.goto("/dashboard"); 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 a[href="/scheduling"]')).toHaveCount(1); // public Overview shown + await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out }); diff --git a/plugins/scheduling/plugin.ts b/plugins/scheduling/plugin.ts index ec71631..bedb213 100644 --- a/plugins/scheduling/plugin.ts +++ b/plugins/scheduling/plugin.ts @@ -3,7 +3,7 @@ // folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md. import { definePlugin } from "../../src/plugin-api.ts"; -import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, READ, SHIFTS_PATH, WRITE } from "./shifts.ts"; +import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, 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). @@ -17,10 +17,14 @@ export default definePlugin({ // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") }, - // Merged into the global menu + filtered per user: the "Shifts" leaf shows only for a user holding - // `scheduling:read`, so the whole "Scheduling" header disappears for everyone else. + // Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling" + // header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data + // stays hidden until a reader signs in (§10 — a plugin may make a page + its menu option public). nav: [{ - children: [{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ }], + children: [ + { href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true }, + { href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ }, + ], icon: "i-cal", id: "scheduling", label: "Scheduling", @@ -32,8 +36,10 @@ export default definePlugin({ { description: "Create and edit shifts", token: WRITE }, ], - // Mounted under /scheduling; `permission` gates before the handler runs. + // Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public` + // (anyone may reach /scheduling, signed in or not); the rest need a role. routes: [ + { handler: overview(), method: "GET", path: "/", public: true }, { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ }, { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, diff --git a/plugins/scheduling/shifts.test.ts b/plugins/scheduling/shifts.test.ts index dbdf911..8019c76 100644 --- a/plugins/scheduling/shifts.test.ts +++ b/plugins/scheduling/shifts.test.ts @@ -6,7 +6,7 @@ import test from "node:test"; // refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches. import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "../../src/plugin-api.ts"; import { - assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, readInput, + assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, } from "./shifts.ts"; @@ -112,6 +112,18 @@ test("listShifts degrades to a recoverable error page when the upstream is down assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); }); +// ---- public overview handler (§10: a page anyone can reach, gated data stays behind the role) ---- + +test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => { + const anon = asView(await overview()(fakeCtx())); // user null, no roles + assert.equal(anon.view, "overview"); + assert.equal(anon.data["chrome"], CHROME); + assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link + + const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] }))); + assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list +}); + // ---- create handler ---- test("newShiftForm renders the empty form", async () => { diff --git a/plugins/scheduling/shifts.ts b/plugins/scheduling/shifts.ts index 3e66a3a..c2e376e 100644 --- a/plugins/scheduling/shifts.ts +++ b/plugins/scheduling/shifts.ts @@ -8,6 +8,7 @@ // One import from the host's plugin-api barrel — the stable author surface (see docs/plugin-contract.md). import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "../../src/plugin-api.ts"; +export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page (§10) 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 @@ -184,6 +185,17 @@ export function newShiftForm(): RouteHandler { return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" }); } +// Public overview (§10): a page anyone may reach — its route + nav node are marked `public`, so the +// gate lets an anonymous visitor through and the menu option shows for everyone. The real data +// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone +// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O). +export function overview(): RouteHandler { + return (ctx) => ({ + data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" }, + view: "overview", + }); +} + export function createShift(upstream: ShiftsUpstream): RouteHandler { return async (ctx) => { const form = await readFormBody(ctx.req); diff --git a/plugins/scheduling/views/overview.ejs b/plugins/scheduling/views/overview.ejs new file mode 100644 index 0000000..95e5c2e --- /dev/null +++ b/plugins/scheduling/views/overview.ejs @@ -0,0 +1,24 @@ +<%# + Scheduling · public overview (reference plugin, §10). A page ANYONE may reach — the route and its + nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for + everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to + it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome. + Data: chrome, title, breadcrumbs, canRead, shiftsHref +%><% + const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); + const cta = canRead + ? 'View shifts' + : 'Sign in to view shifts'; +-%> +<%- include("partials/shell", { + actions: "", + body: '

Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the scheduling:read role.

' + cta + '
', + brand: chrome.brand, + breadcrumbs, + csrfToken: chrome.csrfToken, + nav: navHtml, + styles: ["/public/scheduling/scheduling.css"], + theme: chrome.theme, + title, + user: chrome.user, +}) %> diff --git a/src/app.test.ts b/src/app.test.ts index 95e30aa..3b64b35 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -329,6 +329,7 @@ const demoPlugin: Plugin = { { handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" }, { handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" }, { handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" }, + { handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // §10 blessed public { handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" }, ], }; @@ -383,6 +384,11 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per assert.equal(denied.status, 303); assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret"); + // a route marked public (§10) is reachable anonymously — no gate, no redirect. + const open = await fetch(url + "/demo/public-page", { redirect: "manual" }); + assert.equal(open.status, 200); + assert.match(await open.text(), /open to all/); + // known path + wrong method → 405 with Allow; unknown path → 404 const wrong = await fetch(url + "/demo/data", { method: "DELETE" }); assert.equal(wrong.status, 405); @@ -393,9 +399,11 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per 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. + // The view composes the core shell from ctx.chrome — branding, the global nav — and its own + // CSRF-guarded form carrying chrome.csrfToken (the representative way a plugin form gets the token, + // independent of the shell's auth-dependent profile/sign-out block). 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 }) %>`); + `<%- include("partials/shell", { body: '
', 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 = { @@ -422,7 +430,7 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct 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). + // whose token is embedded in the plugin's own form (double-submit). const res = await fetch(url + "/panelkit/panel"); assert.equal(res.status, 200); const body = await res.text(); diff --git a/src/discovery.test.ts b/src/discovery.test.ts index 131d387..1ac3a88 100644 --- a/src/discovery.test.ts +++ b/src/discovery.test.ts @@ -51,6 +51,8 @@ const badCases: Array<{ name: string; files: Record; match: RegE { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/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: "a route marked public AND permission is contradictory (§10)", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s }, + { name: "a nav node marked public AND permission is contradictory (§10)", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s }, { name: "two plugins claim the public 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/ }, { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, ]; @@ -61,6 +63,14 @@ for (const c of badCases) { }); } +test("a route + nav node may be marked public (§10) and load fine", async (t) => { + const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` }); + const plugins = await discoverPlugins({ dir }); + assert.equal(plugins.length, 1); + assert.equal(plugins[0]?.routes?.[0]?.public, true); + assert.equal(plugins[0]?.nav?.[0]?.public, true); +}); + test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers (§10)", async (t) => { const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` }); const plugins = await discoverPlugins({ dir }); diff --git a/src/discovery.ts b/src/discovery.ts index 647dbdd..07183ab 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -93,6 +93,23 @@ function shapeError(manifest: PluginManifest): string | null { for (const slot of ["home", "dashboard"] as const) { if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`; } + // `public` and `permission` are contradictory on the same route/nav node (§10) — "open to all" vs + // "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous. + for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { + if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`; + } + const navContradiction = findPublicNavContradiction(manifest.nav); + if (navContradiction) return navContradiction; + return null; +} + +// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory (§10). +function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null { + for (const node of Array.isArray(nodes) ? nodes : []) { + if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`; + const inChild = findPublicNavContradiction(node?.children); + if (inChild) return inChild; + } return null; } diff --git a/src/jwt-middleware.test.ts b/src/jwt-middleware.test.ts index bf1b00b..04c0dc5 100644 --- a/src/jwt-middleware.test.ts +++ b/src/jwt-middleware.test.ts @@ -59,7 +59,9 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => { test("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => { assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/); + assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/); + assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it) assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).roles, []); // roles absent assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]); }); diff --git a/src/jwt-middleware.ts b/src/jwt-middleware.ts index 2e8ad7f..4deeb68 100644 --- a/src/jwt-middleware.ts +++ b/src/jwt-middleware.ts @@ -58,13 +58,14 @@ export function validateClaims(payload: Record, options: Verify } } -// Map verified claims → the request User. sub/email are required (the tokenizer always sets -// them); roles defaults to [] and keeps only string entries (defensive). +// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer +// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and +// keeps only string entries (defensive). export function claimsToUser(payload: Record): User { const sub = payload["sub"]; if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub"); const email = payload["email"]; - if (typeof email !== "string") throw new TokenError("token missing email"); + if (typeof email !== "string" || email === "") throw new TokenError("token missing email"); const roles = payload["roles"]; return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] }; } diff --git a/src/nav.test.ts b/src/nav.test.ts index 87fca94..3d6880e 100644 --- a/src/nav.test.ts +++ b/src/nav.test.ts @@ -45,6 +45,22 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga assert.deepEqual(composeNav(), []); }); +test("composeNav keeps a node marked public for everyone — the blessed public alias (§10)", () => { + // A header with one public child + one gated child: with no roles, the public child keeps the + // header alive (the gated child is filtered out) — so a plugin can show a public menu option to all. + const frag: NavNode[][] = [[{ + icon: "i-cal", id: "sched", label: "Scheduling", + children: [ + { href: "/scheduling", id: "overview", label: "Overview", public: true }, + { href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" }, + ], + }]]; + // `public` is filter-only (like id/permission) — never rendered into the output node. + assert.deepEqual(composeNav(frag, {}, []), [ + { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] }, + ]); +}); + test("composeNav applies the override: rename, group, order, hide (then filters)", () => { const base: NavNode[][] = [[ { href: "/a", id: "a", label: "Alpha" }, diff --git a/src/nav.ts b/src/nav.ts index b73bdee..53c235a 100644 --- a/src/nav.ts +++ b/src/nav.ts @@ -1,7 +1,7 @@ // composeNav (todo §1): merge each plugin's nav fragment into one tree, apply the central // override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT -// `roles` claim (README "The menu system"), never Keto. A node is visible iff it declares no -// `permission` or `roles` includes that permission token; a gated header hides its whole +// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or +// declares no `permission`, or `roles` includes that permission token; a gated header hides its whole // subtree, and a pure header left with no children is dropped. The §2 config/menu.ts supplies // the override (+ branding); this helper only transforms data, so its result is per-deployment // up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/permission). @@ -16,6 +16,7 @@ export interface NavNode { label: string; open?: boolean; permission?: string; // required role token; consumed by the filter, never rendered + public?: boolean; // §10: show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both). } // Central override (config/menu.ts, §2). Targets nodes by `id`; applied rename → group → @@ -105,7 +106,7 @@ function hideTree(nodes: NavNode[], hide: Set): NavNode[] { function filterByRoles(nodes: NavNode[], roles: Set): NavNode[] { const out: NavNode[] = []; for (const n of nodes) { - if (n.permission != null && !roles.has(n.permission)) continue; // gated → drop node + subtree + if (n.public !== true && n.permission != null && !roles.has(n.permission)) continue; // gated → drop node + subtree (public always shows) if (!n.children) { out.push(n); continue; } const children = filterByRoles(n.children, roles); if (children.length === 0 && n.href == null) continue; // empty pure header → drop diff --git a/src/plugin.ts b/src/plugin.ts index f37374c..1f08485 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -30,6 +30,10 @@ export interface Route { method: HttpMethod; path: string; // relative to the plugin's mount path `/`; ":name" segments → ctx.params.name permission?: string; // coarse gate (a role token); checked before the handler runs + // Mark the page reachable by anyone, signed in or not (§10). The same as omitting `permission` + // — a no-permission route is already open — but stated outright, so "public" is a deliberate + // choice, not an accident. Mutually exclusive with `permission` (discovery refuses both). + public?: boolean; } // A permission token this plugin introduces — declared for docs/seeding. Tokens are a shared diff --git a/src/router.test.ts b/src/router.test.ts index 9148c25..b441e9f 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -55,11 +55,13 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the assert.deepEqual(allowedMethods(plugins, "/x/missing"), []); }); -test("isAuthorized: open routes pass; gated routes require the role token", () => { +test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => { const open: Route = { handler: noop, method: "GET", path: "/" }; const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" }; + const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // §10 blessed public alias assert.equal(isAuthorized(open, []), true); assert.equal(isAuthorized(gated, []), false); assert.equal(isAuthorized(gated, ["x:read"]), true); assert.equal(isAuthorized(gated, ["other"]), false); + assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright }); diff --git a/src/router.ts b/src/router.ts index 78b4277..009127c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -74,8 +74,9 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] { return [...methods].sort(); } -// Coarse permission gate: a route with no `permission` is open; otherwise the user's roles (from -// the session JWT, §4) must include the token. The same rule composeNav uses for the menu. +// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise +// the user's roles (from the session JWT, §4) must include the token. The same rule composeNav uses +// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both, §10). export function isAuthorized(route: Route, roles: string[]): boolean { - return route.permission == null || roles.includes(route.permission); + return route.public === true || route.permission == null || roles.includes(route.permission); } diff --git a/src/shell.test.ts b/src/shell.test.ts index 2f70dd9..2c7054e 100644 --- a/src/shell.test.ts +++ b/src/shell.test.ts @@ -12,6 +12,7 @@ test("app shell renders sidebar, topbar and the content slot", async () => { title: "People", brand: { name: "Acme Console", sub: "v2" }, csrfToken: "tok.sig", + user: { email: "ada@acme.io", initials: "AD", name: "ada" }, // a signed-in identity → profile + Sign out nav: 'Overview', body: '
page
', actions: '', @@ -42,6 +43,12 @@ test("app shell renders sidebar, topbar and the content slot", async () => { assert.match(html, //); // hamburger references the menu icon }); +test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a public page in the shell works (§10)", async () => { + const html = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x" }); // no user → Guest + assert.match(html, /href="\/login"[^>]*>[\s\S]*?Sign in/); // a path to sign in + assert.doesNotMatch(html, /action="\/logout"/); // a guest has no session to end +}); + test("app shell renders a configured logo + default theme, falls back to the brand mark", async () => { const branded = await render({ brand: { logo: "/public/brand/logo.svg", name: "Acme" }, theme: "dark" }); assert.match(branded, /1 plugin claims either slot (new `"home"`/`"dashboard"` conflict kinds), `discovery.shapeError` rejects a non-function handler, and `"dashboard"` is added to `RESERVED_PLUGIN_IDS` so a plugin folder can't shadow the built-in route (`/` can't be shadowed — route paths always carry the `/` prefix). Post-login + already-signed-in redirects now target `/dashboard`; the global "Dashboard"/"People" nav hrefs moved `/`→`/dashboard` (chrome/admin-nav/dashboard). Tests-first (348 units, was 338): `app.test.ts` public-`/` (anon 200 + login/register, no gate) + gated-`/dashboard` (anon→303 return_to) + dual plugin-override; `plugin.test.ts` per-slot conflict; `discovery.test.ts` non-function + reserved-id + two-owners + valid-load. Docs: `docs/plugin-contract.md` ("The landing pages (home & dashboard)" section + manifest/conflict/reserved updates), README. **E2E:** the Ory-free `visual.spec.ts` plants a dev-signed session for the `/dashboard` design-system tests + a cookie-free public-`/` landing test (login/register links, screenshot verified); `full-flow.spec.ts` repointed its app-shell navigations to `/dashboard`; all five e2e healthchecks moved off the (now-public-but-formless) `/` to the auth-free `/public/css/styles.css`. stability-reviewer on the prod diff (both iterations): **APPROVE, no Critical/High/Medium** (gate moved correctly + stays closed, open-redirect-safe, public `/` prints no protected data, no shadowing either direction, render-branch parity). typecheck + 348 units + visual (10) + 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. \ No newline at end of file +- [x] 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. → A no-permission route/nav node is already anonymous-reachable; this **blesses** it as a first-class, explicit choice (per human pick: keep that default, add an explicit alias — not a secure-by-default flip). New optional **`public?: boolean`** on `Route` (`src/plugin.ts`) and `NavNode` (`src/nav.ts`) means "open to everyone, signed in or not" — honored explicitly in `isAuthorized` (`router.ts`) + `filterByRoles` (`nav.ts`), and **mutually exclusive with `permission`** (discovery `shapeError` recursively rejects a route/nav node setting both → fails the boot loud). `public` is filter-only (`toRenderNode` never emits it). The shell (`views/partials/shell.ejs`) now renders a **Sign in** link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (`ctx.chrome`, `ctx.user` may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public `/scheduling` **Overview** route + public "Overview" nav child, with the shifts list still behind `scheduling:read` (so the "Scheduling" header now shows for everyone, the data doesn't). Hardened a latent gap the shell newly leans on: `claimsToUser` now rejects an **empty** email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (`public` open + reject-both + loads), shell (anonymous → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: `docs/plugin-contract.md` ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: `visual.spec` asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: **APPROVE, no Critical/High/Medium** (addressed its one Low — the empty-email hardening). typecheck + 354 units + full `scripts/ci.sh` gate (visual 10 · auth 1 · oauth 2 · full 7) green. \ No newline at end of file diff --git a/views/partials/shell.ejs b/views/partials/shell.ejs index 507634a..bb2c9c8 100644 --- a/views/partials/shell.ejs +++ b/views/partials/shell.ejs @@ -45,13 +45,14 @@ <%- include("theme-switch", { value: locals.theme }) %>