§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:
22
README.md
22
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.
|
||||
|
||||
Reference in New Issue
Block a user