Say where the one-gate rule applies, and record why allows stays open

This commit is contained in:
2026-09-02 14:03:06 +02:00
parent 6a0d11d9d3
commit 390ac5f112
3 changed files with 10 additions and 8 deletions
+3 -1
View File
@@ -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 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. 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 `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 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 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 lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
+4 -3
View File
@@ -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"] } }); export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
``` ```
Every nav item names one gate — a `permission`, **`public: true`** (everyone) or **`session: true`** Every nav node a **plugin** declares names one gate — a `permission`, **`public: true`** (everyone)
(anyone signed in); the rendered tree is **filtered per user** from the session JWT (no per-request or **`session: true`** (anyone signed in); a header this override groups takes none, and shows
authz call), so the menu only shows what that person can reach. 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. Branding (name, logo, default theme) renders in the app shell.
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders **One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
+3 -4
View File
@@ -35,10 +35,9 @@ export function actionForMethod(method: string): AdminAction {
return verb === "GET" || verb === "HEAD" ? "read" : "write"; return verb === "GET" || verb === "HEAD" ? "read" : "write";
} }
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own // The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read
// read permission. The header carries no `permission` because a user may hold one screen's and not // permission. composeNav drops a header left with no visible children, so a user holding none of
// another's; composeNav drops a header left with no visible children, so a user holding none of the // them never sees the section. The host current-marks the active item — no `current`/`open` here.
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
export const ADMIN_NAV: NavNode = { export const ADMIN_NAV: NavNode = {
children: [ children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") }, { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },