Gate a route or nav node on a session, not only a permission #103

Merged
lilleman merged 12 commits from session-gate into main 2026-09-03 17:47:05 +02:00
3 changed files with 10 additions and 8 deletions
Showing only changes of commit 390ac5f112 - Show all commits
+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
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
+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"] } });
```
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
+3 -4
View File
@@ -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") },