§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

@@ -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 `/<id>`** (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 `/<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`,
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 `/<id>`,
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: