§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,
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.