diff --git a/src/plugin-host/conflicts.ts b/src/plugin-host/conflicts.ts index 423340d..8f3640b 100644 --- a/src/plugin-host/conflicts.ts +++ b/src/plugin-host/conflicts.ts @@ -39,7 +39,7 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] { for (const entry of plugin.profileMenu ?? []) if (entry.id != null) push(entry.id); }; collect(plugins, overrideTargets).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) }); + if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `id "${id}" used ${owners.length}× across nav nodes and profile-menu entries; the menu override targets ids, so they must be unique`, plugins: uniq(owners) }); }); collect(plugins, (plugin, push) => { diff --git a/src/plugin-host/discovery.test.ts b/src/plugin-host/discovery.test.ts index 5ade0f8..a7fc49c 100644 --- a/src/plugin-host/discovery.test.ts +++ b/src/plugin-host/discovery.test.ts @@ -59,7 +59,7 @@ const badCases: Array<{ name: string; files: Record; match: RegE // The folder name becomes a Postgres identifier, which truncates past 63 bytes. { name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "${HOST_API_VERSION}", storage: true };` }, match: /storage.*56 characters/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, - { 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: "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: /id "dup" used 2× across nav nodes and profile-menu entries/ }, { 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 }, diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index 8a66cad..03092ec 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -95,7 +95,7 @@ test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newe // 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", () => { +test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field), `admin` and `auth-admin` (plugins) are NOT reserved", () => { const seg = (path: string): string => path.split("/")[1] ?? ""; // first segment of "/x/y" const builtins = new Set([ ...Object.keys(AUTH_FLOWS).map(seg), // /login, /recovery, /registration, /settings, /verification @@ -109,7 +109,9 @@ test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / f for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`); // "/" is owned by the `home` manifest field (not a / route), so it cannot be shadowed and is // deliberately not reserved — a plugin folder named "home" is legal. `auth-admin` is likewise free: - // the admin screens ship as a drop-in plugin mounted at /auth-admin, not a built-in route. + // the admin screens ship as a drop-in plugin mounted at /auth-admin, not a built-in route; and so + // is `admin`, the id an operator's pre-0.5 copy of that plugin still carries. assert.equal(RESERVED_PLUGIN_IDS.has("home"), false); assert.equal(RESERVED_PLUGIN_IDS.has("auth-admin"), false); + assert.equal(RESERVED_PLUGIN_IDS.has("admin"), false); }); diff --git a/src/ui/chrome.test.ts b/src/ui/chrome.test.ts index 373821e..7708ed3 100644 --- a/src/ui/chrome.test.ts +++ b/src/ui/chrome.test.ts @@ -114,7 +114,7 @@ test("profile menu entries are gated like nav nodes, translated by their plugin assert.deepEqual(entries({ signedIn: false }).map((e) => e.label), []); // an anonymous visitor has no profile menu }); -test("the central override hides and renames a profile-menu entry by its id, which is not rendered", () => { +test("the central override hides and renames a profile-menu entry by its id; the host translates the new label", () => { const links = buildPluginChrome({ menu: { ...DEFAULT_MENU, override: { hide: ["help"], rename: { settings: "Preferences" } } }, plugins: [{ @@ -122,9 +122,13 @@ test("the central override hides and renames a profile-menu entry by its id, whi profileMenu: [ { href: "/account/settings", id: "settings", label: "account.settings", session: true }, { href: "/account/help", id: "help", label: "Help", public: true }, + { href: "/account/keys", id: "toString", label: "Keys", session: true }, ], }], + t: (key) => `host:${key}`, + translatorFor: () => (key) => `plugin:${key}`, user: { email: "ada@x.io", id: "u1", permissions: [] }, }).user.profileLinks; - assert.deepEqual(links, [{ href: "/account/settings", label: "Preferences" }]); + // An id that names an Object.prototype member is renamed only when the override says so. + assert.deepEqual(links, [{ href: "/account/settings", label: "host:Preferences" }, { href: "/account/keys", label: "plugin:Keys" }]); }); diff --git a/src/ui/chrome.ts b/src/ui/chrome.ts index 83e816e..68ca09a 100644 --- a/src/ui/chrome.ts +++ b/src/ui/chrome.ts @@ -93,7 +93,7 @@ function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: str const pluginT = opts.translatorFor?.(plugin.id) ?? t; const shown = (plugin.profileMenu ?? []).filter((entry) => allows(entry, user) && !(entry.id != null && hide.includes(entry.id))); return shown.map((entry) => { - const renamed = entry.id != null ? rename[entry.id] : undefined; + const renamed = entry.id != null && Object.hasOwn(rename, entry.id) ? rename[entry.id] : undefined; return { href: carryLocale(entry.href), ...(entry.icon != null ? { icon: entry.icon } : {}), diff --git a/src/ui/menu-config.ts b/src/ui/menu-config.ts index d897c67..d0f0119 100644 --- a/src/ui/menu-config.ts +++ b/src/ui/menu-config.ts @@ -1,5 +1,6 @@ // Central menu config: config/menu.ts lets an operator set branding (app name, logo, -// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/ +// default theme), reorder/rename/group/hide nav nodes and rename/hide profile-menu entries across +// all plugins. The reorder/rename/ // group/hide part is the NavOverride composeNav already applies (the override always wins, before // the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at // boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config). diff --git a/src/ui/nav.ts b/src/ui/nav.ts index a1b6f6e..b8224dc 100644 --- a/src/ui/nav.ts +++ b/src/ui/nav.ts @@ -52,7 +52,7 @@ export function composeNav( function renameTree(nodes: NavNode[], rename: Record): NavNode[] { return nodes.map((n) => { - const renamed = n.id != null ? rename[n.id] : undefined; + const renamed = n.id != null && Object.hasOwn(rename, n.id) ? rename[n.id] : undefined; return { ...n, label: renamed != null ? renamed : n.label,