Split conflict detection out of plugin.ts and the CI/CD section out of the README
CI / full-gate (push) Successful in 3m22s
CI / full-gate (push) Successful in 3m22s
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { findConflicts } from "./conflicts.ts";
|
||||
import type { Plugin } from "./plugin.ts";
|
||||
|
||||
// A minimal discovered plugin (id = folder name; mount path is the derived `/<id>`), per case.
|
||||
const p = (over: Partial<Plugin> & Pick<Plugin, "id">): Plugin => ({ apiVersion: "1.0.0", ...over });
|
||||
|
||||
test("findConflicts: a clean set has none", () => {
|
||||
assert.deepEqual(findConflicts([p({ id: "a" }), p({ id: "b" })]), []);
|
||||
});
|
||||
|
||||
test("findConflicts: a duplicate id and a colliding route are loud errors", () => {
|
||||
const dupId = findConflicts([p({ id: "a" }), p({ id: "a" })]);
|
||||
assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error"));
|
||||
|
||||
// Cross-plugin routes can't collide (unique `/<id>` prefix); two identical routes in one can.
|
||||
const noop = () => {};
|
||||
const dupRoute = findConflicts([p({
|
||||
id: "a",
|
||||
routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }],
|
||||
})]);
|
||||
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
|
||||
});
|
||||
|
||||
test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => {
|
||||
const navDup = findConflicts([
|
||||
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
||||
]);
|
||||
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
|
||||
// Sharing a permission across plugins is legitimate → warn, not error.
|
||||
const permissionDup = findConflicts([
|
||||
p({ id: "a", permissions: [{ name: "shared:read" }] }),
|
||||
p({ id: "b", permissions: [{ name: "shared:read" }] }),
|
||||
]);
|
||||
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||
});
|
||||
|
||||
const noopHandler = () => {};
|
||||
|
||||
test("findConflicts: a route, nav node or profile entry gating on a permission no plugin declares warns once per plugin and name, sorted", () => {
|
||||
const gated = findConflicts([
|
||||
p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [
|
||||
{ handler: noopHandler, method: "GET", path: "/x", permission: "routeonly:read" },
|
||||
{ handler: noopHandler, method: "GET", path: "/y", permission: "both:read" },
|
||||
{ handler: noopHandler, method: "POST", path: "/y", permission: "both:read" },
|
||||
{ handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" },
|
||||
], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }, { href: "/q", label: "Q", session: true }, { href: "/r", label: "R", public: true }] }),
|
||||
p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }),
|
||||
]);
|
||||
assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn"));
|
||||
assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["a"], ["b"]]);
|
||||
assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "profileonly:read", "routeonly:read", "both:read"]);
|
||||
});
|
||||
|
||||
test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => {
|
||||
assert.deepEqual(findConflicts([
|
||||
p({ id: "a", permissions: [{ name: "a:read" }], routes: [{ handler: noopHandler, method: "GET", path: "/x", permission: "a:read" }] }),
|
||||
p({ id: "b", routes: [{ handler: noopHandler, method: "GET", path: "/y", permission: "a:read" }, { handler: noopHandler, method: "GET", path: "/z", session: true }] }),
|
||||
]), []);
|
||||
});
|
||||
|
||||
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
||||
const handler = () => ({ html: "x" });
|
||||
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
|
||||
assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
const dashDup = findConflicts([p({ id: "a", dashboard: handler }), p({ id: "b", dashboard: handler })]);
|
||||
assert.ok(dashDup.some((c) => c.kind === "dashboard" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
// One owner of each (even both on one plugin) is fine.
|
||||
assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
// Cross-plugin conflict detection over the discovered set; discovery throws on any "error" and logs
|
||||
// every "warn". The per-plugin rules live in plugin.ts.
|
||||
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import { declaredPermissions, fullPath, type Plugin } from "./plugin.ts";
|
||||
import { envName } from "./settings.ts";
|
||||
|
||||
export interface PluginConflict {
|
||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
|
||||
level: "error" | "warn";
|
||||
message: string;
|
||||
plugins: string[]; // unique ids involved
|
||||
}
|
||||
|
||||
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
|
||||
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
|
||||
// names are the one intentional overlap, so they warn rather than error.
|
||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
const out: PluginConflict[] = [];
|
||||
|
||||
const idCounts = new Map<string, number>();
|
||||
for (const plugin of plugins) idCounts.set(plugin.id, (idCounts.get(plugin.id) ?? 0) + 1);
|
||||
for (const [id, n] of idCounts) {
|
||||
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
|
||||
}
|
||||
|
||||
// The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner
|
||||
// each — two plugins claiming either is a loud error, not a race.
|
||||
for (const slot of ["home", "dashboard"] as const) {
|
||||
const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id);
|
||||
if (owners.length > 1) out.push({ kind: slot, level: "error", message: `${owners.length} plugins claim "${slot}" (${owners.join(", ")}); only one may own that page`, plugins: uniq(owners) });
|
||||
}
|
||||
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
|
||||
}).forEach((owners, key) => {
|
||||
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => {
|
||||
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const decl of plugin.permissions ?? []) push(decl.name);
|
||||
}).forEach((owners, name) => {
|
||||
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
out.push(...undeclaredGates(plugins));
|
||||
|
||||
// Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and
|
||||
// plugin "a" key "bC" name one variable — one plugin would silently read the other's value.
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const decl of plugin.settings ?? []) push(envName(plugin.id, decl.key));
|
||||
}).forEach((owners, name) => {
|
||||
if (owners.length > 1) out.push({ kind: "setting", level: "error", message: `${owners.length} settings resolve to "${name}"; rename a key or a plugin folder`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Declaring is optional, but the admin screens offer only declared names: a route gated on one nobody
|
||||
// declares answers 403 (a nav node is hidden) until the Keto tuple is written by hand or
|
||||
// ADMIN_PERMISSIONS seeds it.
|
||||
function undeclaredGates(plugins: Plugin[]): PluginConflict[] {
|
||||
const declared = new Set(declaredPermissions(plugins).map((decl) => decl.name));
|
||||
const out: PluginConflict[] = [];
|
||||
for (const plugin of plugins) {
|
||||
const gated = new Set<string>();
|
||||
for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission);
|
||||
collectNavPermissions(plugin.nav, (name) => gated.add(name));
|
||||
for (const entry of plugin.profileMenu ?? []) if (entry.permission != null) gated.add(entry.permission);
|
||||
for (const name of [...gated].sort()) {
|
||||
if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's \`permissions\``, plugins: [plugin.id] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectNavPermissions(nodes: NavNode[] | undefined, push: (name: string) => void): void {
|
||||
for (const node of nodes ?? []) {
|
||||
if (node.permission != null) push(node.permission);
|
||||
collectNavPermissions(node.children, push);
|
||||
}
|
||||
}
|
||||
|
||||
// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count).
|
||||
function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map<string, string[]> {
|
||||
const owners = new Map<string, string[]>();
|
||||
for (const plugin of plugins) emit(plugin, (key) => owners.set(key, [...(owners.get(key) ?? []), plugin.id]));
|
||||
return owners;
|
||||
}
|
||||
|
||||
function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void): void {
|
||||
for (const node of nodes ?? []) {
|
||||
if (node.id != null) push(node.id);
|
||||
collectNavIds(node.children, push);
|
||||
}
|
||||
}
|
||||
|
||||
function uniq(xs: string[]): string[] {
|
||||
return [...new Set(xs)];
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { type Gate, gatesSet } from "../auth/gate.ts";
|
||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||
import { findConflicts } from "./conflicts.ts";
|
||||
import { checkApiVersion, 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";
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
checkApiVersion,
|
||||
declaredPermissions,
|
||||
definePlugin,
|
||||
findConflicts,
|
||||
HOST_API_VERSION,
|
||||
isValidPermissionName,
|
||||
isValidPluginId,
|
||||
@@ -94,75 +93,6 @@ test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newe
|
||||
}
|
||||
});
|
||||
|
||||
// A minimal discovered plugin (id = folder name; mount path is the derived `/<id>`), per case.
|
||||
const p = (over: Partial<Plugin> & Pick<Plugin, "id">): Plugin => ({ apiVersion: "1.0.0", ...over });
|
||||
|
||||
test("findConflicts: a clean set has none", () => {
|
||||
assert.deepEqual(findConflicts([p({ id: "a" }), p({ id: "b" })]), []);
|
||||
});
|
||||
|
||||
test("findConflicts: a duplicate id and a colliding route are loud errors", () => {
|
||||
const dupId = findConflicts([p({ id: "a" }), p({ id: "a" })]);
|
||||
assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error"));
|
||||
|
||||
// Cross-plugin routes can't collide (unique `/<id>` prefix); two identical routes in one can.
|
||||
const noop = () => {};
|
||||
const dupRoute = findConflicts([p({
|
||||
id: "a",
|
||||
routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }],
|
||||
})]);
|
||||
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
|
||||
});
|
||||
|
||||
test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => {
|
||||
const navDup = findConflicts([
|
||||
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
||||
]);
|
||||
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
|
||||
// Sharing a permission across plugins is legitimate → warn, not error.
|
||||
const permissionDup = findConflicts([
|
||||
p({ id: "a", permissions: [{ name: "shared:read" }] }),
|
||||
p({ id: "b", permissions: [{ name: "shared:read" }] }),
|
||||
]);
|
||||
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||
});
|
||||
|
||||
const noopHandler = () => {};
|
||||
|
||||
test("findConflicts: a route, nav node or profile entry gating on a permission no plugin declares warns once per plugin and name, sorted", () => {
|
||||
const gated = findConflicts([
|
||||
p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [
|
||||
{ handler: noopHandler, method: "GET", path: "/x", permission: "routeonly:read" },
|
||||
{ handler: noopHandler, method: "GET", path: "/y", permission: "both:read" },
|
||||
{ handler: noopHandler, method: "POST", path: "/y", permission: "both:read" },
|
||||
{ handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" },
|
||||
], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }, { href: "/q", label: "Q", session: true }, { href: "/r", label: "R", public: true }] }),
|
||||
p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }),
|
||||
]);
|
||||
assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn"));
|
||||
assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["a"], ["b"]]);
|
||||
assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "profileonly:read", "routeonly:read", "both:read"]);
|
||||
});
|
||||
|
||||
test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => {
|
||||
assert.deepEqual(findConflicts([
|
||||
p({ id: "a", permissions: [{ name: "a:read" }], routes: [{ handler: noopHandler, method: "GET", path: "/x", permission: "a:read" }] }),
|
||||
p({ id: "b", routes: [{ handler: noopHandler, method: "GET", path: "/y", permission: "a:read" }, { handler: noopHandler, method: "GET", path: "/z", session: true }] }),
|
||||
]), []);
|
||||
});
|
||||
|
||||
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
||||
const handler = () => ({ html: "x" });
|
||||
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
|
||||
assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
const dashDup = findConflicts([p({ id: "a", dashboard: handler }), p({ id: "b", dashboard: handler })]);
|
||||
assert.ok(dashDup.some((c) => c.kind === "dashboard" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
// One owner of each (even both on one plugin) is fine.
|
||||
assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []);
|
||||
});
|
||||
|
||||
// Drift guard: every mount but the auth flows is listed by hand, so a new top-level built-in route
|
||||
// needs a line here too.
|
||||
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) and `admin` (a plugin) are NOT reserved", () => {
|
||||
|
||||
+2
-100
@@ -8,7 +8,7 @@ import type { Gate } from "../auth/gate.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import type { ProfileMenuEntry } from "../ui/chrome.ts";
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
||||
import type { SettingDecl, SettingsOf } from "./settings.ts";
|
||||
import type { StorageCredentials } from "./storage.ts";
|
||||
|
||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
||||
@@ -179,106 +179,8 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
||||
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
||||
}
|
||||
|
||||
export interface PluginConflict {
|
||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
|
||||
level: "error" | "warn";
|
||||
message: string;
|
||||
plugins: string[]; // unique ids involved
|
||||
}
|
||||
|
||||
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
|
||||
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
|
||||
// names are the one intentional overlap, so they warn rather than error.
|
||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
const out: PluginConflict[] = [];
|
||||
|
||||
const idCounts = new Map<string, number>();
|
||||
for (const plugin of plugins) idCounts.set(plugin.id, (idCounts.get(plugin.id) ?? 0) + 1);
|
||||
for (const [id, n] of idCounts) {
|
||||
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
|
||||
}
|
||||
|
||||
// The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner
|
||||
// each — two plugins claiming either is a loud error, not a race.
|
||||
for (const slot of ["home", "dashboard"] as const) {
|
||||
const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id);
|
||||
if (owners.length > 1) out.push({ kind: slot, level: "error", message: `${owners.length} plugins claim "${slot}" (${owners.join(", ")}); only one may own that page`, plugins: uniq(owners) });
|
||||
}
|
||||
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
|
||||
}).forEach((owners, key) => {
|
||||
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => {
|
||||
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const decl of plugin.permissions ?? []) push(decl.name);
|
||||
}).forEach((owners, name) => {
|
||||
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
out.push(...undeclaredGates(plugins));
|
||||
|
||||
// Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and
|
||||
// plugin "a" key "bC" name one variable — one plugin would silently read the other's value.
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const decl of plugin.settings ?? []) push(envName(plugin.id, decl.key));
|
||||
}).forEach((owners, name) => {
|
||||
if (owners.length > 1) out.push({ kind: "setting", level: "error", message: `${owners.length} settings resolve to "${name}"; rename a key or a plugin folder`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Declaring is optional, but the admin screens offer only declared names: a route gated on one nobody
|
||||
// declares answers 403 (a nav node is hidden) until the Keto tuple is written by hand or
|
||||
// ADMIN_PERMISSIONS seeds it.
|
||||
function undeclaredGates(plugins: Plugin[]): PluginConflict[] {
|
||||
const declared = new Set(declaredPermissions(plugins).map((decl) => decl.name));
|
||||
const out: PluginConflict[] = [];
|
||||
for (const plugin of plugins) {
|
||||
const gated = new Set<string>();
|
||||
for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission);
|
||||
collectNavPermissions(plugin.nav, (name) => gated.add(name));
|
||||
for (const entry of plugin.profileMenu ?? []) if (entry.permission != null) gated.add(entry.permission);
|
||||
for (const name of [...gated].sort()) {
|
||||
if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's \`permissions\``, plugins: [plugin.id] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectNavPermissions(nodes: NavNode[] | undefined, push: (name: string) => void): void {
|
||||
for (const node of nodes ?? []) {
|
||||
if (node.permission != null) push(node.permission);
|
||||
collectNavPermissions(node.children, push);
|
||||
}
|
||||
}
|
||||
|
||||
// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count).
|
||||
function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map<string, string[]> {
|
||||
const owners = new Map<string, string[]>();
|
||||
for (const plugin of plugins) emit(plugin, (key) => owners.set(key, [...(owners.get(key) ?? []), plugin.id]));
|
||||
return owners;
|
||||
}
|
||||
|
||||
function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void): void {
|
||||
for (const node of nodes ?? []) {
|
||||
if (node.id != null) push(node.id);
|
||||
collectNavIds(node.children, push);
|
||||
}
|
||||
}
|
||||
|
||||
// A route's full path = the plugin's mount path `/<id>` + the route path. The single source of
|
||||
// truth for both conflict detection (here) and the router, so they can't disagree.
|
||||
// truth for both conflict detection and the router, so they can't disagree.
|
||||
export function fullPath(id: string, path: string): string {
|
||||
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
function uniq(xs: string[]): string[] {
|
||||
return [...new Set(xs)];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user