diff --git a/AGENTS.md b/AGENTS.md index c2d4e5a..960e72f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,7 +186,9 @@ Revisit only if the stated reason stops holding. node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page. `src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and - the menu all read. `session` exists because a plugin whose data is + the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime invariant: + `allows({}, user)` stays open **by design**, because the central override's `groups` builds header + nodes that carry no gate. Making `allows` fail closed would hide every operator-grouped section. `session` exists because a plugin whose data is the visitor's own — their upstream account, their own tokens — has no distinction a permission could name; the alternative, granting every newly registered user a permission, couples the identity lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on diff --git a/README.md b/README.md index e4d42d8..9a30aba 100644 --- a/README.md +++ b/README.md @@ -920,9 +920,10 @@ The menu is **driven entirely by config** and assembled from two sources: export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } }); ``` -Every nav item names one gate — a `permission`, **`public: true`** (everyone) or **`session: true`** -(anyone signed in); the rendered tree is **filtered per user** from the session JWT (no per-request -authz call), so the menu only shows what that person can reach. +Every nav node a **plugin** declares names one gate — a `permission`, **`public: true`** (everyone) +or **`session: true`** (anyone signed in); a header this override groups takes none, and shows +whenever a child does. The rendered tree is **filtered per user** from the session JWT (no +per-request authz call), so the menu only shows what that person can reach. Branding (name, logo, default theme) renders in the app shell. **One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index 3c41068..8e8b424 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -35,10 +35,9 @@ export function actionForMethod(method: string): AdminAction { return verb === "GET" || verb === "HEAD" ? "read" : "write"; } -// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own -// read permission. The header carries no `permission` because a user may hold one screen's and not -// another's; composeNav drops a header left with no visible children, so a user holding none of the -// three never sees the section. The host current-marks the active item — no `current`/`open` here. +// The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read +// permission. composeNav drops a header left with no visible children, so a user holding none of +// them never sees the section. The host current-marks the active item — no `current`/`open` here. export const ADMIN_NAV: NavNode = { children: [ { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },