Gate a route or nav node on a session, not only a permission
CI / full-gate (push) Successful in 3m6s
CI / full-gate (push) Successful in 3m6s
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { allows, gatesSet } from "./gate.ts";
|
||||
|
||||
const holder: User = { email: "holder@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions: ["x:read"] };
|
||||
const stranger: User = { email: "stranger@example.test", id: "01a06091-baa3-7b4d-810a-c9ee7e559d98", permissions: [] };
|
||||
|
||||
test("allows: ungated and public are open to anyone; session needs a user; permission needs the token", () => {
|
||||
assert.equal(allows({}, null), true);
|
||||
assert.equal(allows({ public: true }, null), true);
|
||||
|
||||
assert.equal(allows({ session: true }, null), false);
|
||||
assert.equal(allows({ session: true }, stranger), true); // signed in is the whole gate — no grant
|
||||
|
||||
assert.equal(allows({ permission: "x:read" }, null), false);
|
||||
assert.equal(allows({ permission: "x:read" }, stranger), false);
|
||||
assert.equal(allows({ permission: "x:read" }, holder), true);
|
||||
});
|
||||
|
||||
test("gatesSet names the gates a declaration sets, so discovery can refuse more than one", () => {
|
||||
assert.deepEqual(gatesSet({}), []);
|
||||
assert.deepEqual(gatesSet({ session: true }), ["session"]);
|
||||
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
|
||||
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
|
||||
// `false` is not a gate — only a set one counts, so { session: false } is an ungated route.
|
||||
assert.deepEqual(gatesSet({ public: false, session: false }), []);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// The coarse gate a route or nav node declares. One home for the rule, so the router and the menu
|
||||
// can never disagree about what a visitor may reach.
|
||||
import type { User } from "../http/context.ts";
|
||||
|
||||
// Widest first: whoever passes an earlier gate passes it without holding anything.
|
||||
const GATES = ["public", "session", "permission"] as const;
|
||||
|
||||
export interface Gate {
|
||||
permission?: string | undefined; // the Keto Permission the caller must hold
|
||||
public?: boolean | undefined; // anyone, signed in or not
|
||||
session?: boolean | undefined; // any signed-in user, no grant needed
|
||||
}
|
||||
|
||||
export function allows(gate: Gate, user: User | null): boolean {
|
||||
if (gate.public === true) return true;
|
||||
if (gate.session === true) return user !== null;
|
||||
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
|
||||
}
|
||||
|
||||
// Which gates a declaration sets — discovery refuses more than one, since they contradict.
|
||||
export function gatesSet(gate: Gate | null | undefined): string[] {
|
||||
if (gate == null) return [];
|
||||
return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true));
|
||||
}
|
||||
+3
-2
@@ -28,7 +28,8 @@ import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
||||
import type { PluginSettings } from "../plugin-host/settings.ts";
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||
import { allows } from "../auth/gate.ts";
|
||||
import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
import { localPath } from "./safe-url.ts";
|
||||
@@ -281,7 +282,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||
if (!allows(match.route, routeCtx.user)) {
|
||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
||||
// lacks the permission gets the 403 page.
|
||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||
|
||||
@@ -62,6 +62,9 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
||||
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
||||
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
||||
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
||||
{ name: "a route marked session AND permission is contradictory", files: { "contrasess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contrasess.*session.*permission/s },
|
||||
{ name: "a route marked public AND session is contradictory", files: { "contrapub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, session: true, handler: () => ({ html: "x" }) }] };` }, match: /contrapub.*public.*session/s },
|
||||
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
|
||||
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
||||
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
||||
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
||||
@@ -96,12 +99,19 @@ test("a discovery failure tells the operator their plugins/ copy may just be out
|
||||
});
|
||||
});
|
||||
|
||||
test("a route + nav node may be marked public and load fine", async (t) => {
|
||||
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
|
||||
test("a route + nav node may be marked public, or session, and load fine", async (t) => {
|
||||
const dir = scaffold(t, {
|
||||
"pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };`,
|
||||
"sess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/sess", id: "s", label: "S", session: true }], routes: [{ method: "GET", path: "/", session: true, handler: () => ({ html: "x" }) }] };`,
|
||||
});
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
assert.equal(plugins.length, 1);
|
||||
assert.equal(plugins[0]?.routes?.[0]?.public, true);
|
||||
assert.equal(plugins[0]?.nav?.[0]?.public, true);
|
||||
assert.equal(plugins.length, 2);
|
||||
const pub = plugins.find((p) => p.id === "pub");
|
||||
const sess = plugins.find((p) => p.id === "sess");
|
||||
assert.equal(pub?.routes?.[0]?.public, true);
|
||||
assert.equal(pub?.nav?.[0]?.public, true);
|
||||
assert.equal(sess?.routes?.[0]?.session, true);
|
||||
assert.equal(sess?.nav?.[0]?.session, true);
|
||||
});
|
||||
|
||||
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { gatesSet } from "../auth/gate.ts";
|
||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||
import { settingsDeclError } from "./settings.ts";
|
||||
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
||||
@@ -146,12 +147,13 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
const settings = settingsDeclError(manifest.settings);
|
||||
if (settings) return settings;
|
||||
}
|
||||
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
||||
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
||||
// Two gates on one route or nav node contradict each other — "open to all" vs "needs a session"
|
||||
// vs "needs this permission". Refuse rather than silently pick one, so intent stays unambiguous.
|
||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
|
||||
const gates = gatesSet(route);
|
||||
if (gates.length > 1) return `route "${route?.method} ${route?.path}" sets ${gates.join(" and ")}; a route names exactly one gate — public, session or permission`;
|
||||
}
|
||||
const navContradiction = findPublicNavContradiction(manifest.nav);
|
||||
const navContradiction = findNavGateContradiction(manifest.nav);
|
||||
if (navContradiction) return navContradiction;
|
||||
// Every permission name the manifest mentions — gated on or declared — must be `<resource>:<action>`.
|
||||
// A bare word names a role, and roles are groups here (README → Naming a permission).
|
||||
@@ -170,11 +172,12 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
|
||||
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
|
||||
// Recurse the nav fragment: a node naming more than one gate is contradictory, same as a route.
|
||||
function findNavGateContradiction(nodes: PluginManifest["nav"]): string | null {
|
||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
|
||||
const inChild = findPublicNavContradiction(node?.children);
|
||||
const gates = gatesSet(node);
|
||||
if (gates.length > 1) return `nav node "${node?.label ?? node?.id ?? "?"}" sets ${gates.join(" and ")}; a node names exactly one gate — public, session or permission`;
|
||||
const inChild = findNavGateContradiction(node?.children);
|
||||
if (inChild) return inChild;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface Route {
|
||||
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
|
||||
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
|
||||
public?: boolean;
|
||||
// Any signed-in user, no grant to hold — for a plugin whose data is the visitor's own. Anonymous
|
||||
// is bounced to /login, never 403. Mutually exclusive with the other two (discovery refuses both).
|
||||
session?: boolean;
|
||||
}
|
||||
|
||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Plugin, Route } from "./plugin.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||
import { allowedMethods, matchRoute } from "./router.ts";
|
||||
|
||||
const noop: Route["handler"] = () => ({ html: "x" });
|
||||
|
||||
@@ -54,14 +54,3 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
|
||||
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
|
||||
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
||||
});
|
||||
|
||||
test("isAuthorized: open routes pass; gated routes require the permission token; public is explicitly open", () => {
|
||||
const open: Route = { handler: noop, method: "GET", path: "/" };
|
||||
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
|
||||
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
|
||||
assert.equal(isAuthorized(open, []), true);
|
||||
assert.equal(isAuthorized(gated, []), false);
|
||||
assert.equal(isAuthorized(gated, ["x:read"]), true);
|
||||
assert.equal(isAuthorized(gated, ["other"]), false);
|
||||
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
|
||||
});
|
||||
|
||||
@@ -73,10 +73,3 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
|
||||
}
|
||||
return [...methods].sort();
|
||||
}
|
||||
|
||||
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
|
||||
// the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
|
||||
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
|
||||
export function isAuthorized(route: Route, permissions: string[]): boolean {
|
||||
return route.public === true || route.permission == null || permissions.includes(route.permission);
|
||||
}
|
||||
|
||||
+1
-2
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user