§10 public pages + menu items, the blessed explicit alias (todo §10); a plugin may mark a page and its menu option public. A no-permission route/nav node is already anonymous-reachable, so per the human's pick this BLESSES that as a first-class, explicit choice (keep the default; add an explicit alias — not a secure-by-default flip). New optional public?: boolean on Route (src/plugin.ts) + NavNode (src/nav.ts) = "open to everyone, signed in or not", honored outright in isAuthorized (router.ts) + filterByRoles (nav.ts), and MUTUALLY EXCLUSIVE with permission — discovery shapeError recursively rejects a route/nav node setting both, failing the boot loud (never silently picks one). 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 + a public "Overview" nav child (the "Scheduling" header now shows for everyone), the shifts list still behind scheduling:read. Hardened the latent gap the shell newly leans on: claimsToUser rejects an empty email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (public open + reject-both + loads), shell (anon → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). 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.

This commit is contained in:
2026-06-20 18:12:46 +02:00
parent 7787ed4ea4
commit 7bdeb24b7f
20 changed files with 210 additions and 45 deletions

View File

@@ -358,9 +358,9 @@ registration step, no central wiring. The full, authoritative API surface —
manifest shape, handler/`RequestContext` contract, versioning, conflict rules, manifest shape, handler/`RequestContext` contract, versioning, conflict rules,
hooks, and the dev/test story — is **[docs/plugin-contract.md](docs/plugin-contract.md)** hooks, and the dev/test story — is **[docs/plugin-contract.md](docs/plugin-contract.md)**
(`src/plugin.ts` holds the types). A complete, runnable reference ships in (`src/plugin.ts` holds the types). A complete, runnable reference ships in
**[`plugins/scheduling/`](plugins/scheduling/)** — a list page fetching upstream data, **[`plugins/scheduling/`](plugins/scheduling/)** — a public overview page, a permission-gated
a CSRF-guarded form forwarding writes upstream, and permission-gated nav. Copy it and list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, and a mix of
adapt. The sketch below is the shape. 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 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 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 ```ts
import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs) 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({ export default definePlugin({
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see docs) 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 // 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: [ nav: [
{ {
label: "Scheduling", icon: "i-cal", label: "Scheduling", icon: "i-cal",
children: [ children: [
{ label: "Overview", href: "/scheduling", public: true }, // shown to everyone
{ label: "Shifts", href: "/scheduling/shifts", permission: "scheduling:read" }, { label: "Shifts", href: "/scheduling/shifts", permission: "scheduling:read" },
], ],
}, },
], ],
// Route handlers, mounted under the plugin's path (/scheduling). `permission` is a coarse role // 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: [ routes: [
{ method: "GET", path: "/", public: true, handler: overview },
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts }, { 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 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 user** by reading the roles in the session JWT (no per-request authz call — see
[Auth, sessions & permissions](#auth-sessions--permissions)), so the menu [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, 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 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. brand shows the configured logo (else a default mark), and the theme sets the theme-switch default.

View File

@@ -109,14 +109,16 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
## Routes & handlers ## Routes & handlers
A route is `{ method, path, permission?, handler }`. `path` is **relative to the plugin's mount A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
path `/<id>`** (so `/shifts` in the `scheduling` plugin serves `/scheduling/shifts`); the host mount path `/<id>`** (so `/shifts` in the `scheduling` plugin serves `/scheduling/shifts`); the host
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, 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 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 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 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 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`. `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 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 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 node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use
depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a **Lucide arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are **Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
`ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there. 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` **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 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 `/<id>`,
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns 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. 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 ## Hooks
Optional, for reacting to system actions. A plugin's `hooks` may implement: Optional, for reacting to system actions. A plugin's `hooks` may implement:

View File

@@ -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(); await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
}); });
// 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 public Overview is
// permission-gated, so an anonymous visitor is bounced to sign in (and never sees it in the nav). // reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
// The authenticated list/form flow is the §8 full E2E (full-flow.spec). Side-effect-free. // so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the §8 full
test("the reference plugin is permission-gated: anonymous → redirect to /login, hidden from the dashboard nav", async ({ page, request }) => { // E2E (full-flow.spec). Side-effect-free.
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so this test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login (§10)", async ({ page, request }) => {
// probe is genuinely anonymous. Don't follow the redirect (this Ory-free suite has no /login // `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
// handler); assert the gate's 303 itself, with the requested page preserved as return_to (§9). // 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 }); 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. // 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 page.goto("/dashboard");
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 a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
}); });

View File

@@ -3,7 +3,7 @@
// folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md. // folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md.
import { definePlugin } from "../../src/plugin-api.ts"; 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 // 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). // 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. // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") }, hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
// Merged into the global menu + filtered per user: the "Shifts" leaf shows only for a user holding // Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling"
// `scheduling:read`, so the whole "Scheduling" header disappears for everyone else. // 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: [{ 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", icon: "i-cal",
id: "scheduling", id: "scheduling",
label: "Scheduling", label: "Scheduling",
@@ -32,8 +36,10 @@ export default definePlugin({
{ description: "Create and edit shifts", token: WRITE }, { 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: [ routes: [
{ handler: overview(), method: "GET", path: "/", public: true },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ }, { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },

View File

@@ -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. // 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 { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "../../src/plugin-api.ts";
import { 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, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts"; } 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, []); 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 ---- // ---- create handler ----
test("newShiftForm renders the empty form", async () => { test("newShiftForm renders the empty form", async () => {

View File

@@ -8,6 +8,7 @@
// One import from the host's plugin-api barrel — the stable author surface (see docs/plugin-contract.md). // 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"; 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 SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // permission token gating the list + nav export const READ = "scheduling:read"; // permission token gating the list + nav
export const WRITE = "scheduling:write"; // permission token gating create 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" }); 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 { export function createShift(upstream: ShiftsUpstream): RouteHandler {
return async (ctx) => { return async (ctx) => {
const form = await readFormBody(ctx.req); const form = await readFormBody(ctx.req);

View File

@@ -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
? '<a class="btn btn-primary" href="' + shiftsHref + '">View shifts</a>'
: '<a class="btn btn-primary" href="/login?return_to=' + encodeURIComponent(shiftsHref) + '">Sign in to view shifts</a>';
-%>
<%- include("partials/shell", {
actions: "",
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
nav: navHtml,
styles: ["/public/scheduling/scheduling.css"],
theme: chrome.theme,
title,
user: chrome.user,
}) %>

View File

@@ -329,6 +329,7 @@ const demoPlugin: Plugin = {
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" }, { handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" }, { handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" }, { 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" }, { 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.status, 303);
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret"); 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 // known path + wrong method → 405 with Allow; unknown path → 404
const wrong = await fetch(url + "/demo/data", { method: "DELETE" }); const wrong = await fetch(url + "/demo/data", { method: "DELETE" });
assert.equal(wrong.status, 405); 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) => { 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-")); const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "panelkit", "views"), { recursive: true }); 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"), 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: '<form method="post" action="/panelkit/save"><input type="hidden" name="_csrf" value="' + chrome.csrfToken + '" /></form>', 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 })); t.after(() => rmSync(dir, { force: true, recursive: true }));
const plugin: Plugin = { 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}`; const url = `http://localhost:${(app.address() as AddressInfo).port}`;
// GET renders the shell: branding (DEFAULT_MENU), the (ungated) plugin nav, and a CSRF cookie // 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"); const res = await fetch(url + "/panelkit/panel");
assert.equal(res.status, 200); assert.equal(res.status, 200);
const body = await res.text(); const body = await res.text();

View File

@@ -51,6 +51,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { 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: "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: "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 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/ }, { 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) => { 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 dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });

View File

@@ -93,6 +93,23 @@ function shapeError(manifest: PluginManifest): string | null {
for (const slot of ["home", "dashboard"] as const) { 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)`; 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; return null;
} }

View File

@@ -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", () => { 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/);
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({ 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", sub: "u" }).roles, []); // roles absent
assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]); assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
}); });

View File

@@ -58,13 +58,14 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
} }
} }
// Map verified claims → the request User. sub/email are required (the tokenizer always sets // Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
// them); roles defaults to [] and keeps only string entries (defensive). // 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<string, unknown>): User { export function claimsToUser(payload: Record<string, unknown>): User {
const sub = payload["sub"]; const sub = payload["sub"];
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub"); if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
const email = payload["email"]; 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"]; const roles = payload["roles"];
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] }; return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] };
} }

View File

@@ -45,6 +45,22 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
assert.deepEqual(composeNav(), []); 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)", () => { test("composeNav applies the override: rename, group, order, hide (then filters)", () => {
const base: NavNode[][] = [[ const base: NavNode[][] = [[
{ href: "/a", id: "a", label: "Alpha" }, { href: "/a", id: "a", label: "Alpha" },

View File

@@ -1,7 +1,7 @@
// composeNav (todo §1): merge each plugin's nav fragment into one tree, apply the central // 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 // 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 // `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// `permission` or `roles` includes that permission token; a gated header hides its whole // 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 // 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 // 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). // 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; label: string;
open?: boolean; open?: boolean;
permission?: string; // required role token; consumed by the filter, never rendered 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 → // Central override (config/menu.ts, §2). Targets nodes by `id`; applied rename → group →
@@ -105,7 +106,7 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] { function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
const out: NavNode[] = []; const out: NavNode[] = [];
for (const n of nodes) { 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; } if (!n.children) { out.push(n); continue; }
const children = filterByRoles(n.children, roles); const children = filterByRoles(n.children, roles);
if (children.length === 0 && n.href == null) continue; // empty pure header → drop if (children.length === 0 && n.href == null) continue; // empty pure header → drop

View File

@@ -30,6 +30,10 @@ export interface Route {
method: HttpMethod; method: HttpMethod;
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
permission?: string; // coarse gate (a role token); checked before the handler runs 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 // A permission token this plugin introduces — declared for docs/seeding. Tokens are a shared

View File

@@ -55,11 +55,13 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []); 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 open: Route = { handler: noop, method: "GET", path: "/" };
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" }; 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(open, []), true);
assert.equal(isAuthorized(gated, []), false); assert.equal(isAuthorized(gated, []), false);
assert.equal(isAuthorized(gated, ["x:read"]), true); assert.equal(isAuthorized(gated, ["x:read"]), true);
assert.equal(isAuthorized(gated, ["other"]), false); assert.equal(isAuthorized(gated, ["other"]), false);
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
}); });

View File

@@ -74,8 +74,9 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
return [...methods].sort(); return [...methods].sort();
} }
// Coarse permission gate: a route with no `permission` is open; otherwise the user's roles (from // Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
// the session JWT, §4) must include the token. The same rule composeNav uses for the menu. // 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 { 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);
} }

View File

@@ -12,6 +12,7 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
title: "People", title: "People",
brand: { name: "Acme Console", sub: "v2" }, brand: { name: "Acme Console", sub: "v2" },
csrfToken: "tok.sig", csrfToken: "tok.sig",
user: { email: "ada@acme.io", initials: "AD", name: "ada" }, // a signed-in identity → profile + Sign out
nav: '<a id="nav-marker" href="/x">Overview</a>', nav: '<a id="nav-marker" href="/x">Overview</a>',
body: '<section id="body-marker">page</section>', body: '<section id="body-marker">page</section>',
actions: '<button id="action-marker">Add</button>', actions: '<button id="action-marker">Add</button>',
@@ -42,6 +43,12 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
assert.match(html, /<use href="#i-menu"\s*\/?>/); // hamburger references the menu icon assert.match(html, /<use href="#i-menu"\s*\/?>/); // 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 () => { 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" }); const branded = await render({ brand: { logo: "/public/brand/logo.svg", name: "Acme" }, theme: "dark" });
assert.match(branded, /<img class="brand-logo" src="\/public\/brand\/logo\.svg"/); assert.match(branded, /<img class="brand-logo" src="\/public\/brand\/logo\.svg"/);

View File

@@ -137,4 +137,4 @@ everything via Docker.
## 10. User added stuff ## 10. User added stuff
- [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. → **Two replaceable landing slots** (per the human follow-up: `/` public, `/dashboard` gated): `/` is now an **ungated public landing** (default `views/home.ejs`: brand + a short "what plainpages is" intro + prominent **Log in**/`/login` & **Create account**/`/registration` links, or **Go to your dashboard** when already signed in), and `/dashboard` is the **gated post-login app home** (anonymous → `/login?return_to=/dashboard` via `loginRedirect`; default = the mock-data People list). Both **fully replaceable by a plugin** via two optional `RouteHandler`s on `PluginManifest` (`src/plugin.ts`) — `home?` (public `/`) and `dashboard?` (gated `/dashboard`), the most ergonomic shape (same signature as any route). The host renders each against the plugin's own `views/` with the native shell via `ctx.chrome` (full parity with a plugin route: HEAD, `void`-return, response hooks, fresh CSRF cookie); a `home` handler is public so `ctx.user` may be null. **Single-slot, loud:** `findConflicts` errors when >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 `/<id>` 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. - [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. → **Two replaceable landing slots** (per the human follow-up: `/` public, `/dashboard` gated): `/` is now an **ungated public landing** (default `views/home.ejs`: brand + a short "what plainpages is" intro + prominent **Log in**/`/login` & **Create account**/`/registration` links, or **Go to your dashboard** when already signed in), and `/dashboard` is the **gated post-login app home** (anonymous → `/login?return_to=/dashboard` via `loginRedirect`; default = the mock-data People list). Both **fully replaceable by a plugin** via two optional `RouteHandler`s on `PluginManifest` (`src/plugin.ts`) — `home?` (public `/`) and `dashboard?` (gated `/dashboard`), the most ergonomic shape (same signature as any route). The host renders each against the plugin's own `views/` with the native shell via `ctx.chrome` (full parity with a plugin route: HEAD, `void`-return, response hooks, fresh CSRF cookie); a `home` handler is public so `ctx.user` may be null. **Single-slot, loud:** `findConflicts` errors when >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 `/<id>` 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. - [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.

View File

@@ -45,13 +45,14 @@
<%- include("theme-switch", { value: locals.theme }) %> <%- include("theme-switch", { value: locals.theme }) %>
<div class="footer-actions"> <div class="footer-actions">
<%# profile menu stays inline: the summary composes escaped user values %> <% if (user.email) { %>
<%# signed in: profile menu inline (the summary composes escaped user values) %>
<details class="menu" style="flex:1 1 auto"> <details class="menu" style="flex:1 1 auto">
<summary class="profile"> <summary class="profile">
<span class="avatar" aria-hidden="true"><%= user.initials %></span> <span class="avatar" aria-hidden="true"><%= user.initials %></span>
<span class="profile-meta"> <span class="profile-meta">
<span class="profile-name"><%= user.name %></span> <span class="profile-name"><%= user.name %></span>
<% if (user.email) { %><span class="profile-mail"><%= user.email %></span><% } %> <span class="profile-mail"><%= user.email %></span>
</span> </span>
</summary> </summary>
<div class="menu-pop left up" style="min-width:220px"> <div class="menu-pop left up" style="min-width:220px">
@@ -64,6 +65,10 @@
</form> </form>
</div> </div>
</details> </details>
<% } else { %>
<%# anonymous (a public page in the shell, §10): no session to end — offer a way in instead %>
<a class="btn btn-primary" href="/login" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg>Sign in</a>
<% } %>
<%- include("menu", { <%- include("menu", {
up: true, up: true,