Gate a route or nav node on a session, not only a permission
CI / full-gate (push) Successful in 3m6s

This commit is contained in:
2026-09-02 07:36:08 +02:00
parent 4ad8653a06
commit 8da75b4ca7
21 changed files with 250 additions and 75 deletions
+1 -2
View File
@@ -44,8 +44,7 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
}
const permissions = opts.user?.permissions ?? [];
const nav = composeNav(fragments, opts.menu.override, permissions, t);
const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t);
if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
+19 -9
View File
@@ -1,7 +1,12 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { User } from "../http/context.ts";
import { composeNav, type NavNode } from "./nav.ts";
function viewer(...permissions: string[]): User {
return { email: "viewer@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions };
}
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
const fragments: NavNode[][] = [
[{
@@ -15,7 +20,7 @@ const fragments: NavNode[][] = [
];
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
const tree = composeNav(fragments, {}, ["scheduling:read"]);
const tree = composeNav(fragments, {}, viewer("scheduling:read"));
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
@@ -30,7 +35,7 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
{ id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
]];
assert.deepEqual(composeNav(gatedHeader, {}, []), [
assert.deepEqual(composeNav(gatedHeader, {}, viewer()), [
{ label: "Free", children: [{ href: "/d", label: "Docs" }] },
]);
@@ -39,26 +44,31 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] },
]];
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
assert.deepEqual(composeNav(emptyHeader, {}, viewer()), [{ href: "/hub", label: "Hub" }]);
// No fragments / no permissions → empty tree, never throws.
assert.deepEqual(composeNav(), []);
});
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
// A header with one public child + one gated child: with no permissions, the public child keeps the
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
test("composeNav shows a public node to everyone and a session node to any signed-in user", () => {
// A header with a public child, a session child and a gated child: the public child keeps the
// header alive for an anonymous visitor — so a plugin can show a menu option to all.
const frag: NavNode[][] = [[{
icon: "i-cal", id: "sched", label: "Scheduling",
children: [
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
{ href: "/scheduling/mine", id: "mine", label: "Mine", session: true },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
],
}]];
// `public` is filter-only (like id/permission) — never rendered into the output node.
assert.deepEqual(composeNav(frag, {}, []), [
// `public`/`session` are filter-only (like id/permission) — never rendered into the output node.
assert.deepEqual(composeNav(frag, {}, null), [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
]);
// Signed in with no permission at all: the session node appears, the permission-gated one does not.
assert.deepEqual(composeNav(frag, {}, viewer()), [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }, { href: "/scheduling/mine", label: "Mine" }] },
]);
});
test("composeNav applies the override: rename, group, order, hide (then filters)", () => {
@@ -74,7 +84,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
order: ["grp", "a"], // grp before the lone a
hide: ["c"], // remove c from inside the group
}, ["secrets:read"]);
}, viewer("secrets:read"));
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present.
assert.deepEqual(tree, [
+11 -8
View File
@@ -1,8 +1,10 @@
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
// permission-filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim,
// never Keto. A node is visible iff it is `public`, declares no `permission`, or the user holds that
// name; a gated header hides its whole subtree, and a pure header left with no children is dropped.
// filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim, never Keto.
// A node is visible iff `allows` passes its gate; a gated header hides its whole subtree, and a pure
// header left with no children is dropped.
import { allows } from "../auth/gate.ts";
import type { User } from "../http/context.ts";
import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts";
@@ -17,6 +19,7 @@ export interface NavNode {
open?: boolean;
permission?: string; // required permission token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
session?: boolean; // show to any signed-in user, no grant to hold; consumed by the filter, never rendered. Mutually exclusive with the other two (discovery refuses both).
}
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
@@ -39,7 +42,7 @@ export interface NavGroupSpec {
export function composeNav(
fragments: NavNode[][] = [],
override: NavOverride = {},
permissions: string[] = [],
user: User | null = null,
t: Translate = ENGLISH,
): NavNode[] {
let nodes: NavNode[] = fragments.flat();
@@ -47,7 +50,7 @@ export function composeNav(
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
if (override.order?.length) nodes = applyOrder(nodes, override.order);
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
return filterByRoles(nodes, new Set(permissions)).map((node) => toRenderNode(node, t));
return filterByGate(nodes, user).map((node) => toRenderNode(node, t));
}
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
@@ -104,12 +107,12 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
return out;
}
function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
function filterByGate(nodes: NavNode[], user: User | null): NavNode[] {
const out: NavNode[] = [];
for (const n of nodes) {
if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
if (!allows(n, user)) continue; // gated → drop node + subtree
if (!n.children) { out.push(n); continue; }
const children = filterByRoles(n.children, permissions);
const children = filterByGate(n.children, user);
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
out.push({ ...n, children });
}