@murumets-ee/admin-route 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/admin-route.ts","../src/permissions/resolved.ts","../src/define-admin-route.ts"],"mappings":"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"}
package/dist/index.mjs ADDED
@@ -0,0 +1,2 @@
1
+ const e=Symbol(`lumi.admin-route.gated`);function t(t){return typeof t==`object`&&!!t&&e in t&&t[e]===!0}const n=Symbol(`lumi.admin-route.entry`),r=new WeakSet,i=new Map;function a(e){let t=i.get(e.permission);if(t){let n=new Set([...t.defaultRoles,...e.defaultRoles]),r={...t,defaultRoles:[...n],description:t.description??e.description};return i.set(e.permission,r),r}let n={...e,defaultRoles:[...e.defaultRoles]};return i.set(e.permission,n),n}function o(){return new Map(i)}function s(){i.clear()}function c(){for(let[e,t]of i)t.source===`feature`&&i.delete(e)}function l(e){let t=e.indexOf(`:`),n=e.lastIndexOf(`:`);if(t<=0||n===e.length-1||t!==n)throw Error(`defineAdminRoute: malformed permission '${e}' — expected '<resource>:<action>' with exactly one colon`);return{resource:e.slice(0,t),action:e.slice(t+1)}}function u(e,t){try{e.audit?.(t)}catch{}}function d(e,t){u(e,{action:`permission.denied`,entityType:`permission`,userId:e.user.id,...e.user.name!==void 0&&{userName:e.user.name},metadata:{permission:t.permission,...e.user.role!==void 0&&{role:e.user.role},method:t.method,prefix:t.prefix,path:t.path,segments:e.segments,...t.extraMetadata}})}function f(e,t){return new Response(JSON.stringify({error:`Forbidden: role '${t??`(unknown)`}' lacks permission '${e}'`,code:`forbidden`}),{status:403,headers:{"content-type":`application/json`}})}function p(e){if(e.path.includes(`/`))throw Error(`defineAdminRoute: path '${e.path}' contains '/' — only single-segment paths are dispatched by combineAdminRoutes. Read trailing segments from ctx.segments[1..] inside the handler instead.`);let t=e.matchAnyPath??!1;if(t&&e.path!==``)throw Error(`defineAdminRoute: matchAnyPath requires path: '' (got '${e.path}' for '${e.method} ${e.prefix}'). Catch-all entries claim the prefix-root + every unmatched sub-path; static paths take precedence.`);let{resource:i,action:o}=l(e.permission),s=e.defaultRoles??[];a({resource:i,action:o,permission:e.permission,defaultRoles:s,description:e.description,source:`route`});let c=async(t,n)=>n.checkPermission(i,o)?e.handler(t,n):(d(n,{permission:e.permission,method:e.method,prefix:e.prefix,path:e.path}),f(e.permission,n.user.role));return r.add(c),{[n]:!0,prefix:e.prefix,path:e.path,method:e.method,permission:e.permission,resource:i,action:o,defaultRoles:s,description:e.description,handler:e.handler,guardedHandler:c,matchAnyPath:t}}function m(e){if(e.resource.length===0)throw Error(`defineFeature: 'resource' must be a non-empty string`);if(e.resource.trim().length===0)throw Error(`defineFeature: 'resource' must not be whitespace-only (got '${e.resource}')`);if(e.resource!==e.resource.trim())throw Error(`defineFeature: 'resource' '${e.resource}' has leading/trailing whitespace — would silently produce a duplicate catalog key vs. the trimmed form. Pass '${e.resource.trim()}' instead.`);if(e.resource.includes(`:`))throw Error(`defineFeature: 'resource' '${e.resource}' must not contain ':' — that's the action separator. Use dot-notation for namespacing (e.g. 'ticketing.bulk-edit').`);if(e.actions.length===0)throw Error(`defineFeature: 'actions' for resource '${e.resource}' must be a non-empty array — declare at least one action`);let t=new Set;for(let n of e.actions){if(n.length===0)throw Error(`defineFeature: action in resource '${e.resource}' must be a non-empty string`);if(n.trim().length===0)throw Error(`defineFeature: action in resource '${e.resource}' must not be whitespace-only (got '${n}')`);if(n!==n.trim())throw Error(`defineFeature: action '${n}' for resource '${e.resource}' has leading/trailing whitespace — would silently produce a duplicate catalog key vs. the trimmed form. Pass '${n.trim()}' instead.`);if(n.includes(`:`))throw Error(`defineFeature: action '${n}' for resource '${e.resource}' must not contain ':' — the catalog stores '<resource>:<action>' so a colon inside the action is ambiguous`);if(t.has(n))throw Error(`defineFeature: duplicate action '${n}' for resource '${e.resource}' — each action must be unique within a single defineFeature call`);t.add(n)}let n=e.defaultRoles??[],r=[];for(let t of e.actions){let i={resource:e.resource,action:t,permission:`${e.resource}:${t}`,defaultRoles:n,description:e.description,source:`feature`};r.push(a(i))}return r}function h(e){if(typeof e==`object`&&e&&`prefix`in e){let t=e.prefix;if(typeof t==`string`&&t.length>0)return t}return`(unreadable prefix)`}function g(e){return typeof e==`object`&&!!e&&n in e&&e[n]===!0}function _(e){if(typeof e!=`object`||!e||!(`guardedHandler`in e))return!1;let t=e.guardedHandler;return typeof t==`function`&&r.has(t)}function v(t){let n=new Map;for(let[e,r]of t.entries()){if(!g(r))throw Error(`combineAdminRoutes: entry ${e} (prefix '${h(r)}') does not carry the provenance brand that defineAdminRoute() mints, so its handlers cannot be shown to enforce a permission check. TWO possible causes, identical from here: (1) the entry was hand-built as an AdminRouteEntry literal (or cast into one) — such an entry has no permission gate, no catalog registration and no audit-on-deny, yet would mint a fully-branded AdminRoute serving every request under its prefix; build it with defineAdminRoute({ prefix, path, method, permission, handler }) instead. (2) TWO copies of @murumets-ee/admin-route are installed, so the brand minted by one copy is unrecognisable to the other — one package minting entries and another combining them is a supported idiom, so this is NOT an authoring mistake. If the entry IS built with defineAdminRoute(), run \`pnpm why @murumets-ee/admin-route\` and dedupe before changing any code.`);if(!_(r))throw Error(`combineAdminRoutes: entry ${e} (prefix '${h(r)}') carries the provenance brand, but its guardedHandler is not one defineAdminRoute() minted — the permission gate was substituted after the entry was created. That is what a decorator does: \`{ ...entry, guardedHandler: wrapped }\` (or \`entry.guardedHandler = wrapped\`) keeps the brand while discarding the gate. WRAPPING guardedHandler IS NOT SUPPORTED, even faithfully — the combiner cannot verify that an arbitrary wrapper still calls the permission check, and trusting it is exactly the assumption this refuses to make. To add behaviour around a route, wrap the \`handler\` you pass INTO defineAdminRoute({ ... }); the factory then gates your wrapper.`);let t=n.get(r.prefix)??[];t.push(r),n.set(r.prefix,t)}let r=[];for(let[t,i]of n){let n={GET:{exact:new Map,catchAll:null},POST:{exact:new Map,catchAll:null},PATCH:{exact:new Map,catchAll:null},DELETE:{exact:new Map,catchAll:null}};for(let e of i){let r=n[e.method];if(e.matchAnyPath){if(r.catchAll!==null)throw Error(`combineAdminRoutes: duplicate matchAnyPath for '${e.method} ${t}' — at most one matchAnyPath entry per (prefix, method)`);r.catchAll=e.guardedHandler;continue}if(r.exact.has(e.path))throw Error(`combineAdminRoutes: duplicate route '${e.method} ${t}/${e.path}' — each (prefix, method, path) must be unique`);r.exact.set(e.path,e.guardedHandler)}let a=new Map;for(let e of i)a.set(`${e.resource}:${e.action}`,{resource:e.resource,action:e.action});let o=[...a.values()],s={};for(let e of[`GET`,`POST`,`PATCH`,`DELETE`]){let{exact:r,catchAll:i}=n[e];r.size===0&&i===null||(s[e]=y(r,i,{prefix:t,method:e,prefixPermissions:o}))}r.push({[e]:!0,prefix:t,handlers:s})}return r}function y(e,t,n){return async(r,i)=>{let a=i.segments[0]??``,o=e.get(a);if(o)return o(r,i);if(t)return t(r,i);if(n.prefixPermissions.some(e=>i.checkPermission(e.resource,e.action)))return new Response(JSON.stringify({error:`Not found`}),{status:404,headers:{"content-type":`application/json`}});let s=n.prefixPermissions[0]??null;if(s===null)return new Response(JSON.stringify({error:`Not found`}),{status:404,headers:{"content-type":`application/json`}});let c=`${s.resource}:${s.action}`;return d(i,{permission:c,method:n.method,prefix:n.prefix,path:a,extraMetadata:{kind:`dispatch-miss`,requiredAny:n.prefixPermissions.map(e=>`${e.resource}:${e.action}`)}}),f(c,i.user.role)}}export{s as _resetPermissionCatalog,c as clearFeaturePermissions,v as combineAdminRoutes,p as defineAdminRoute,m as defineFeature,d as emitPermissionDenied,o as getPermissionCatalog,t as isGatedRoute,f as permissionDeniedResponse,a as registerPermission,u as safeAudit};
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/admin-route.ts","../src/define-admin-route.ts"],"sourcesContent":["/**\n * Types for the admin API plugin route system.\n *\n * Defined in `@murumets-ee/admin-route` — a dependency-free leaf (F020,\n * F024) — so that every package, including ones that structurally cannot\n * depend on `@murumets-ee/core` (e.g. `@murumets-ee/logging`; `core`\n * imports `logging`, so the reverse edge would close a cycle), can import\n * these types without circular dependencies.\n *\n * `@murumets-ee/core` re-exports these from its own barrel, binding the\n * `TApp` generic below to the real `ToolkitApp` (see `packages/core/src/\n * admin-route.ts`) so existing consumers see the exact same shape they\n * always have — this file itself must stay dependency-free and cannot\n * name `ToolkitApp`.\n */\n\n/**\n * The nominal gating brand carried by every legitimately-minted\n * {@link AdminRoute} (`plan/admin-api-hardening/` PR 3, SD002).\n *\n * ## Why this exists\n *\n * An `AdminRoute` used to be a plain structural shape — `{ prefix,\n * handlers }` — so ANY object literal satisfied it, including one whose\n * handlers perform no permission check at all. That is the exact defect\n * class this plan exists to close: PR 2 proved every route is *currently*\n * gated; this brand makes an ungated one impossible to construct.\n *\n * `combineAdminRoutes` is the SOLE minter, and it accepts ONLY\n * `defineAdminRoute` outputs — `AdminRouteEntry` carries its own brand\n * (`ENTRY_GATED`, `define-admin-route.ts`) which the combiner requires at\n * both the type level and, fail-closed, at runtime. Since\n * `defineAdminRoute` requires a `permission`, \"carries the brand\"\n * transitively means \"went through a permission gate\".\n *\n * That transitivity used to be an unchecked assumption, and it was FALSE:\n * `AdminRouteEntry` was a purely structural, publicly exported shape, so\n * a hand-written literal (with any `guardedHandler` at all, or none) fed\n * to `combineAdminRoutes` produced a genuinely branded `AdminRoute` that\n * passed {@link isGatedRoute} and served every request under its prefix\n * ungated. Finding H1 of PR 3 closed that by branding the entry too; see\n * `ENTRY_GATED`'s doc comment for the full account.\n *\n * ## Threat model — be precise about what this does and does not stop\n *\n * These layers defend against **author error and `as` casts**: a route\n * declared without a gate, by someone who did not realise one was needed.\n * That is the failure mode this plan actually observed, four times over.\n *\n * They do NOT defend against hostile code already running in the server\n * process. Symbols are not capabilities: anything holding a legitimately\n * minted route can read the key off it via `Object.getOwnPropertySymbols`\n * and stamp a forgery. That is not a weakness worth closing — a malicious\n * plugin has arbitrary in-process execution and does not need a forged\n * route to do harm — but do not mistake {@link isGatedRoute} for a\n * sandbox, and do not let it become the justification for loading\n * untrusted plugin code.\n *\n * ## Why a real `Symbol()`, not a `declare const` phantom\n *\n * `@murumets-ee/blocks` brands `BlockRenderer` with a `declare const`\n * (`BLOCK_RENDER_BRAND`, `packages/blocks/src/core/define-theme.ts`) — a\n * pure type-level phantom that is erased at compile time. That is not\n * enough here. Types are erased and a plain-JS plugin never runs `tsc` at\n * all, so the brand alone is *author ergonomics*; the runtime guarantee is\n * {@link isGatedRoute}, checked fail-closed where routes are collected\n * (`getRouteMap` in `@murumets-ee/admin-ui`). A phantom brand would leave\n * that check nothing to read. SD002 locked \"defense in depth, not\n * either/or\" for exactly this reason (OWASP: no single control should be\n * the sole enforcement).\n *\n * ## Why `unique symbol`, and why it is not exported from the barrel\n *\n * `GATED` is exported from THIS MODULE (so `define-admin-route.ts`'s\n * minter can name the key with no cast) but deliberately NOT re-exported\n * from `src/index.ts`, and the package's `exports` map has no deep paths.\n * No code outside this package can name the key, so an ungated object\n * literal is a compile error at the boundary. A `unique symbol` is what\n * makes that airtight: a same-named `Symbol('lumi.admin-route.gated')`\n * declared elsewhere is a DIFFERENT `unique symbol` type and does not\n * satisfy the property — where a string-literal brand would be trivially\n * forgeable.\n *\n * ## Why it is set as a plain, enumerable own property\n *\n * The minter assigns it in the object literal rather than via\n * `Object.defineProperty(..., { enumerable: false })`. It MUST survive\n * object spread (`{ ...route }`), or a route the compiler blessed would\n * lose its brand at runtime and {@link isGatedRoute} would reject it —\n * layer 3 refusing something layer 2 approved. Symbol keys are already\n * excluded from `Object.keys`, `for…in` and `JSON.stringify`, so leaving\n * it enumerable costs nothing in serialization noise.\n *\n * ## Module-instance caveat\n *\n * Symbol identity is per module instance. Every package that mints routes\n * and the one package that checks them (`admin-ui`) all resolve the same\n * `@murumets-ee/admin-route` (single workspace symlink; a single hoisted\n * version once published, since `@murumets-ee/*` is one fixed changeset\n * group), so there is exactly one `GATED`. `Symbol.for()` would survive\n * module duplication but lives in the cross-realm global registry, where\n * any code could re-derive the key — trading the forgery guarantee for a\n * hazard that does not exist here. If duplication ever did occur the\n * failure is loud and fail-safe: every route logs an error and 404s,\n * rather than silently registering ungated.\n */\nexport const GATED: unique symbol = Symbol('lumi.admin-route.gated')\n\n/** Authenticated user returned by the handler's authenticate callback. */\nexport interface AuthUser {\n id: string\n role?: string\n name?: string\n email?: string\n}\n\n/** Fire-and-forget audit log function passed to plugin route handlers. */\nexport type AuditLogFn = (entry: {\n action: string\n entityType?: string\n entityId?: string\n userId?: string\n userName?: string\n changes?: Record<string, unknown>\n metadata?: Record<string, unknown>\n}) => void\n\n/**\n * Synchronous permission checker — `(role, resource, action) => boolean`.\n *\n * Built by `buildPermissionChecker()` from saved role definitions.\n * - `admin` role: always returns `true` (hardcoded safety net)\n * - All other roles: exact match from settings (deny-by-default)\n */\nexport type PermissionChecker = (role: string, resource: string, action: string) => boolean\n\n/**\n * Handler function for a plugin-provided admin API route.\n *\n * Handlers run inside `runWithContextAsync` — `getCurrentApp()`,\n * `getCurrentLocale()`, `getCurrentDefaultLocale()` are available.\n * `ctx.app` carries the SAME running app instance as an explicit,\n * non-optional field rather than requiring the handler to reach for\n * `getCurrentApp()` (which returns `ToolkitApp | undefined` on the\n * `@murumets-ee/core` side). This is dependency injection, not a\n * duplicate mechanism: a \"leaf\" package (per CLAUDE.md's package-\n * boundary rule — `logging`, `settings`, etc.) that needs the running\n * app inside a route handler can read `ctx.app` without importing\n * `@murumets-ee/core` at all. Some of those packages genuinely CANNOT\n * import core — `core` itself imports `logging` (`app.ts`), so\n * `logging` importing `core` back would close a real cycle.\n * `getCurrentApp()` remains available and unchanged for callers\n * already inside core's dependency cone.\n *\n * `TApp` is generic (defaulting to `unknown`) because this leaf package\n * cannot name `ToolkitApp` without depending on `@murumets-ee/core` —\n * see the module-level doc comment (F024). `@murumets-ee/core` binds\n * `TApp` to the real `ToolkitApp` in its own re-exported `AdminRouteHandler`\n * alias, so every existing consumer that imports from `@murumets-ee/core`\n * sees `ctx.app: ToolkitApp` exactly as before.\n *\n * @param req - The incoming request\n * @param ctx.segments - Path segments after the route prefix has been consumed.\n * e.g. for `/api/admin/media/abc-123`, the media route handler gets `['abc-123']`.\n * @param ctx.user - The authenticated user\n * @param ctx.locale - Content locale from the request (query param)\n * @param ctx.defaultLocale - Default locale from handler config\n * @param ctx.audit - Fire-and-forget audit logger (undefined if no auditLogger configured)\n * @param ctx.checkPermission - Bound permission checker for the current user's role.\n * Used by multi-entity routes (e.g. taxonomy) for per-entity permission checks.\n * @param ctx.app - The running app instance — the SAME reference the\n * dispatcher resolved via `getApp()` for this request, not a fresh construction.\n * Required (not optional): there is exactly one production construction site\n * and it always has the value, so every handler can rely on it being present.\n */\nexport type AdminRouteHandler<TApp = unknown> = (\n req: Request,\n ctx: {\n segments: string[]\n user: AuthUser\n locale?: string\n defaultLocale?: string\n audit?: AuditLogFn\n checkPermission: (resource: string, action: string) => boolean\n app: TApp\n },\n) => Promise<Response>\n\n/**\n * A plugin-provided route group for the admin API handler.\n *\n * This is the lower-level shape `createAdminApiHandler` consumes. It is\n * **not constructible by hand**: the {@link GATED} brand below can only be\n * named inside this package, so `combineAdminRoutes` is the only way to\n * produce one — and the combiner in turn accepts only `defineAdminRoute`\n * outputs. Two independent things enforce that on the combiner's input\n * (finding H1 and its residual):\n *\n * - `AdminRouteEntry` carries its own non-exported brand (`ENTRY_GATED`),\n * required at compile time and re-checked at runtime, so a hand-built\n * entry literal is refused.\n * - The combiner additionally requires the entry's `guardedHandler` to\n * be a function `defineAdminRoute` actually minted (tracked in a\n * module-private `WeakSet`). The brand alone was NOT enough: it is an\n * enumerable property, so `{ ...realEntry, guardedHandler: mine }`\n * carried it faithfully while discarding the gate, with zero casts.\n *\n * `defineAdminRoute` requires a `permission` and builds the gate itself,\n * so neither end of that chain admits an ungated handler. There is no\n * \"declare a prefix and gate it yourself\" shape any more; the legacy\n * top-level `resource` / `actions` gate was retired in\n * `plan/admin-api-hardening/` PR 3 (F006).\n *\n * The guarantee is scoped to author error and `as` casts. Code already\n * executing in the process can read either brand off a legitimately\n * minted value — it just cannot get a forged `guardedHandler` past\n * `combineAdminRoutes`, because function identity is not copyable.\n *\n * The centralized handler dispatches to plugin routes by matching the\n * first path segment against the prefix.\n *\n * @example\n * ```typescript\n * // The ONLY shape. Each entry registers its permission in the catalog\n * // and is wrapper-gated automatically (`guardedHandler`), so a route\n * // that reaches dispatch has provably passed a permission check.\n * import { type AdminRoute, combineAdminRoutes, defineAdminRoute } from '@murumets-ee/core'\n *\n * export function pluginRoutes(): AdminRoute[] {\n * return combineAdminRoutes([\n * defineAdminRoute({\n * prefix: 'plugin', path: '', method: 'GET',\n * permission: 'plugin:view',\n * defaultRoles: ['admin', 'editor', 'agent', 'viewer'],\n * handler: async (req, ctx) => { ... },\n * }),\n * defineAdminRoute({\n * prefix: 'plugin', path: '', method: 'POST',\n * permission: 'plugin:create',\n * defaultRoles: ['admin'],\n * handler: async (req, ctx) => { ... },\n * }),\n * ])\n * }\n *\n * // ✗ Does NOT compile — TS2741, property '[GATED]' is missing. An\n * // ungated admin route is unwritable, not merely discouraged.\n * const rogue: AdminRoute = { prefix: 'plugin', handlers: { GET: h } }\n * ```\n */\nexport interface AdminRoute<TApp = unknown> {\n /**\n * Nominal brand — present ONLY on routes minted by\n * `combineAdminRoutes`. See {@link GATED} for the full rationale; the\n * short version is that this property is what makes an ungated admin\n * route a compile error rather than a code-review question.\n */\n readonly [GATED]: true\n /** URL prefix, e.g. 'media' matches `/api/admin/media/*` */\n prefix: string\n /** HTTP method handlers */\n handlers: {\n GET?: AdminRouteHandler<TApp>\n POST?: AdminRouteHandler<TApp>\n PATCH?: AdminRouteHandler<TApp>\n DELETE?: AdminRouteHandler<TApp>\n }\n}\n\n/**\n * Layer 3 of three — the RUNTIME half of the gating guarantee.\n *\n * Reports whether `value` carries the {@link GATED} brand, i.e. whether it\n * was actually produced by `combineAdminRoutes` rather than merely\n * *typed* as an {@link AdminRoute}. Safe to export: it reads the brand, it\n * cannot mint it.\n *\n * The type layer (layer 2) is erased at runtime and never runs at all for\n * a plain-JavaScript plugin or for TypeScript that reached the shape via\n * `as`. Route collection therefore re-checks structurally and refuses\n * anything unbranded — see `getRouteMap` in `@murumets-ee/admin-ui`.\n *\n * The predicate is deliberately `TApp`-agnostic (`AdminRoute<unknown>`):\n * the brand says nothing about which app shape the handlers expect.\n *\n * **Narrowing caveat.** That genericity is not free. `AdminRoute<unknown>`\n * IS assignable to `AdminRoute<SomeApp>` — the handlers are functions\n * taking `ctx.app`, so `TApp` is contravariant and the `unknown`\n * instantiation is the more permissive one. TypeScript therefore treats\n * the predicate type as the narrower candidate and a caller that already\n * held an `AdminRoute<SomeApp>` comes out of the guard holding\n * `AdminRoute<unknown>`, losing the app instantiation. Callers that need\n * to keep it should use a `boolean`-returning assertion instead of\n * narrowing on this predicate — which is what `@murumets-ee/admin-ui`'s\n * `assertGated` does, so nothing in-tree is affected today.\n */\nexport function isGatedRoute(value: unknown): value is AdminRoute {\n return typeof value === 'object' && value !== null && GATED in value && value[GATED] === true\n}\n","/**\n * `defineAdminRoute` — declarative factory for plugin admin API routes.\n *\n * Replaces the hand-wired `AdminRoute` segment-dispatch tables that every\n * plugin currently maintains. Each call defines ONE HTTP method on ONE path\n * within a prefix, with the permission required to access it. The factory:\n *\n * 1. Parses the `permission` string (`'<resource>:<action>'`) into its\n * resource/action parts so the api-handler doesn't have to re-parse.\n * 2. Wraps the user's handler in a 403 gate that checks the permission\n * BEFORE the handler ever sees the request. No path bypasses the gate\n * because the only way to register a route is through this factory\n * (compile-time enforced — `permission` is a required field). The\n * gate also auto-emits a structured `permission.denied` audit entry\n * on every 403 — no per-route audit wiring needed.\n * 3. Registers the route in the process-local catalog (see\n * {@link permissionCatalog}) so the resource is discoverable for\n * role-default seeding and for the Permission Matrix UI (PR-D).\n *\n * Multi-call composition: `combineAdminRoutes(entries)` collapses any number\n * of `defineAdminRoute` outputs into the legacy `AdminRoute[]` shape that\n * `createAdminApiHandler` already consumes. Plugin code goes from\n * hand-maintained `Record<string, Handler>` dispatch tables to one\n * `defineAdminRoute({ ... })` call per endpoint.\n *\n * @example\n * ```ts\n * import { defineAdminRoute, combineAdminRoutes } from '@murumets-ee/core'\n *\n * const replyRoute = defineAdminRoute({\n * prefix: 'ticketing',\n * path: 'reply',\n * method: 'POST',\n * permission: 'ticket:update',\n * defaultRoles: ['admin', 'agent'],\n * handler: async (req, ctx) => {\n * // ctx is the same shape as any AdminRouteHandler context. The\n * // permission check has ALREADY passed by the time this runs.\n * return new Response('ok')\n * },\n * })\n *\n * export function ticketingRoutes(): AdminRoute[] {\n * return combineAdminRoutes([replyRoute, ...moreRoutes])\n * }\n * ```\n */\n\nimport { type AdminRoute, type AdminRouteHandler, GATED } from './admin-route.js'\nimport type { PermissionString } from './permissions/resolved.js'\n\n// ---------------------------------------------------------------------------\n// `TApp` threading (F024)\n// ---------------------------------------------------------------------------\n//\n// This leaf cannot name `ToolkitApp` (see `admin-route.ts`'s module doc) —\n// every type below that transitively mentions `AdminRouteHandler` is\n// generic over `TApp`, defaulting to `unknown`. `@murumets-ee/core` binds\n// `TApp` to the real `ToolkitApp` in its own re-export shim\n// (`packages/core/src/define-admin-route.ts`), so every existing consumer\n// that imports from `@murumets-ee/core` keeps seeing `ctx.app: ToolkitApp`\n// unchanged.\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\n/** HTTP methods the admin API handler dispatches. */\nexport type AdminRouteMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE'\n\n/**\n * Template-literal type that REJECTS multi-segment paths at compile\n * time. A path containing `/` resolves to `never`, so the assignment\n * fails with a TS error pointing at the literal that contains the\n * slash.\n *\n * PR 5 of `PLAN-DECLARATIVE-PLUGINS.md` — addresses foot-gun F3\n * (`defineAdminRoute({ path: 'accounts/:id/poll-now' })` throwing at\n * registration with a runtime error). The runtime check survives as\n * defense-in-depth (a malformed dist that slipped past TS, or a\n * dynamic-string callsite with `as` cast), but typical plugin\n * authors get the error on save, in their editor, before they even\n * try to boot.\n *\n * @example\n * ```ts\n * // ✓ Compiles\n * defineAdminRoute({ path: 'reply', ... })\n *\n * // ✗ TS error — 'reply/:ticketId' is not assignable to 'never'\n * defineAdminRoute({ path: 'reply/:ticketId', ... })\n * ```\n *\n * For URL shapes where `segments[0]` is itself a runtime value\n * (e.g. `/media/<uuid>`), use `{ path: '', matchAnyPath: true }` —\n * `''` (empty) is not multi-segment, so it satisfies the constraint.\n *\n * Same template-literal technique as `PermissionString` (PR #357).\n */\nexport type SegmentPath<P extends string> = P extends `${string}/${string}` ? never : P\n\n/**\n * Spec passed to {@link defineAdminRoute}. `permission` is REQUIRED — a route\n * without an explicit permission can't compile. There is no \"publicly callable\n * admin route\" — see CLAUDE.md security section.\n *\n * Generic over `TApp` (the `ctx.app` type — see the `TApp` threading note\n * above) and `P` (the literal `path` string, constrained by\n * {@link SegmentPath}). Both default so a bare `DefineAdminRouteSpec`\n * type reference (docs, non-instantiating usage) still resolves to the\n * pre-extraction shape (`ctx.app: unknown`, `path: string`).\n */\nexport interface DefineAdminRouteSpec<TApp = unknown, P extends string = string> {\n /**\n * URL prefix segment, e.g. `'ticketing'` for `/api/admin/ticketing/*`.\n * Routes sharing a prefix MUST be passed together to `combineAdminRoutes`\n * — the resulting `AdminRoute` is keyed on this prefix.\n */\n prefix: string\n /**\n * Sub-path within the prefix, e.g. `'reply'` for\n * `/api/admin/ticketing/reply`. Pass `''` for the root path\n * (`/api/admin/<prefix>`).\n *\n * **Single segment only.** `path` must not contain `/`. Dynamic\n * sub-segments (`'reply/:ticketId'`-style paths) are NOT supported by\n * the combiner — it dispatches strictly on `ctx.segments[0]`. Routes\n * accepting dynamic IDs read them from `ctx.segments[1..]` inside the\n * handler; the registered `path` should still be the static prefix\n * (e.g. `'reply'`). The factory throws at registration time on input\n * containing `/` — loud-at-boot is the design.\n *\n * For URL shapes where `segments[0]` itself is a runtime value\n * (e.g. `/media/<uuid>`), set {@link matchAnyPath} = `true` with\n * `path: ''` — the combiner then dispatches every unmatched sub-path\n * through this entry's handler instead of returning dispatch-miss.\n */\n path: SegmentPath<P>\n /** HTTP method this route serves. */\n method: AdminRouteMethod\n /**\n * Permission required, in `'<resource>:<action>'` form. The api-handler\n * verifies the caller's role has this grant before invoking `handler`;\n * a missing grant returns 403 with no handler invocation. The string is\n * also added to the catalog so role-default seeding and the audit UI\n * pick it up automatically.\n *\n * Resolves to the literal union from\n * `@murumets-ee/core`'s {@link PermissionStringRegistry} when the\n * consuming app augments it (see `PermissionString` docs); otherwise\n * falls back to the loose `${string}:${string}` template literal so\n * dynamic per-resource callsites in toolkit packages keep compiling.\n */\n permission: PermissionString\n /**\n * Built-in roles that should be granted this permission on first\n * deploy / on `upsertBuiltInRoles`. Defaults to `[]` (admin always\n * passes via the hardcoded safety net in `buildPermissionChecker`).\n */\n defaultRoles?: readonly string[]\n /**\n * Optional description for the Permission Matrix UI (PR-D) and audit\n * logs. Plain-text, no markdown.\n */\n description?: string\n /** The actual handler — runs only after the permission check passes. */\n handler: AdminRouteHandler<TApp>\n /**\n * Opt-in catch-all flag for plugins whose top-level URL surface embeds a\n * runtime-value segment directly after the prefix (e.g. `/media/<uuid>`,\n * `/media/<uuid>/usage`). When `true`, the combiner's dispatcher\n * delegates to this entry's `guardedHandler` for any `(prefix, method)`\n * sub-path that no other registered `path` claims — instead of\n * returning the default dispatch-miss 404/403.\n *\n * Constraints (enforced at registration / combine time):\n *\n * - `path` MUST be `''`. Static paths take precedence over the\n * catch-all, so the path slot is reserved for the prefix-root.\n * - At most ONE matchAnyPath entry per `(prefix, method)`. Two\n * conflicting catch-alls would silently shadow each other.\n * - The handler is still wrapper-gated on `permission`. The wrapper\n * emits the same `permission.denied` audit on deny as every other\n * `defineAdminRoute`. The TRADE-OFF: when the wrapper passes but\n * the handler internally returns 404 for an unknown sub-path,\n * there's no `kind: 'dispatch-miss'` forensic enrichment on the\n * audit row (the wrapper-gated audit fires on permission deny,\n * not on handler-level 404). For surfaces where the URL space is\n * enumerable by callers with the permission anyway (media's\n * `/media/<uuid>` — any holder of `media:view` can already list\n * IDs via `GET /media`), the missing enrichment carries no\n * forensic loss. Plugins whose sub-path namespace IS sensitive\n * should use static paths instead.\n *\n * **Default `false`.** Use sparingly — static paths + the\n * `kind: 'dispatch-miss'` audit are the preferred shape. The known\n * use case is plugins whose URL surface predates the framework's\n * static-path convention and where breaking clients to retrofit a\n * sub-resource segment isn't worth the audit-enrichment win.\n */\n matchAnyPath?: boolean\n}\n\n/**\n * The nominal gating brand carried by every {@link AdminRouteEntry} that\n * {@link defineAdminRoute} actually produced (`plan/admin-api-hardening/`\n * PR 3, finding H1).\n *\n * ## Why a SECOND brand\n *\n * `GATED` (see `admin-route.ts`) brands the {@link AdminRoute} that\n * `combineAdminRoutes` mints, on the reasoning \"the combiner is fed only\n * by `defineAdminRoute` outputs, so the brand transitively means 'went\n * through a permission gate'\". That premise was false: `AdminRouteEntry`\n * was fully structural and publicly exported (from this package's barrel\n * and re-exported by `@murumets-ee/core`), so a hand-written literal —\n * every field a plain property, `guardedHandler` included — satisfied it.\n * Feeding that literal to `combineAdminRoutes` produced a genuinely\n * branded `AdminRoute` that passed `isGatedRoute`, registered in\n * `getRouteMap`, and served every request under its prefix to any\n * authenticated user with no permission check at all. The permission\n * catalog never learned of it either, so `assertPermissionsResolvable`\n * stayed silent.\n *\n * That is not a hypothetical shape. Returning a bare `AdminRouteEntry` is\n * an established in-repo idiom (`priceCheckRouteEntry`,\n * `replacementRouteEntries` in `@murumets-ee/commerce`) — both legitimately\n * obtain theirs from `defineAdminRoute`, but an author copying the shape\n * and building the literal by hand landed exactly here. Author error, the\n * exact class PR 3 exists to close.\n *\n * Branding the ENTRY closes the gap at the same two layers as `GATED`:\n * the required `readonly` member makes a hand-written literal a compile\n * error, and `combineAdminRoutes` re-checks structurally at runtime for\n * the plain-JS / `as`-cast paths where types are erased.\n *\n * ## Why it is not exported from the barrel\n *\n * Same rule as `GATED`: exported from THIS MODULE (so the minter and the\n * combiner can name the key with no cast) but deliberately NOT re-exported\n * from `src/index.ts`, and the package's `exports` map has no deep paths.\n * A `unique symbol` makes that airtight — a same-named\n * `Symbol('lumi.admin-route.entry')` declared elsewhere is a DIFFERENT\n * `unique symbol` type and does not satisfy the property.\n *\n * ## Why a plain enumerable own property — and why that is not enough\n *\n * Identical rationale to `GATED`: it must survive `{ ...entry }` spread,\n * or an entry the compiler blessed would be rejected at combine time.\n * Symbol keys are already excluded from `Object.keys`, `for…in` and\n * `JSON.stringify`, so enumerability costs nothing in serialization noise.\n *\n * But spread-survival is EXACTLY what makes the envelope the wrong anchor\n * for provenance. The brand travels with a copy while the gate does not:\n *\n * ```ts\n * const legit = defineAdminRoute({ …real permission, real gate… })\n * combineAdminRoutes([{ ...legit, guardedHandler: async () => new Response('PWNED') }])\n * ```\n *\n * That literal carries a genuine `ENTRY_GATED`, typechecks with zero casts\n * and zero suppressions, and — until `MINTED_GUARDS` — combined into\n * a fully-branded `AdminRoute` that served its whole prefix ungated. It is\n * strictly EASIER than the hand-built literal this brand closed, which\n * needed `as unknown as` plus eleven hand-written fields.\n *\n * So the brand is only ONE of the two layers. The runtime provenance\n * anchor is `MINTED_GUARDS`, which is keyed on the identity of the\n * `guardedHandler` function itself — the thing that actually holds the\n * gate, and the thing a decorator swaps. The brand keeps its own job:\n * making a hand-written literal a COMPILE error, which a `WeakSet` can\n * never do.\n *\n * ## Threat model\n *\n * Unchanged from `GATED`: this stops author error and `as` casts, not\n * hostile code already executing in the process, which can read the key\n * off any legitimately minted entry via `Object.getOwnPropertySymbols`.\n */\nexport const ENTRY_GATED: unique symbol = Symbol('lumi.admin-route.entry')\n\n/**\n * Runtime provenance anchor for {@link AdminRouteEntry.guardedHandler}\n * (`plan/admin-api-hardening/` PR 3, finding H1 — residual).\n *\n * Every `guardedHandler` {@link defineAdminRoute} mints is registered here.\n * {@link combineAdminRoutes} refuses any entry whose `guardedHandler` is\n * absent from the set.\n *\n * ## Why the gate and not the envelope\n *\n * {@link ENTRY_GATED} brands the entry OBJECT. Objects get copied — and a\n * copy can keep the brand while replacing the one field that matters:\n * `{ ...entry, guardedHandler: mine }`. Function identity cannot be copied.\n * A `WeakSet` keyed on the minted function is therefore the tightest\n * possible statement of \"this exact gate came out of the factory\", and it\n * is immune to every reshaping of the surrounding object.\n *\n * A plain spread that does NOT touch `guardedHandler` still passes, because\n * the spread preserves the original function REFERENCE. That is the whole\n * point: legitimate copies keep working, gate substitutions do not.\n *\n * ## The API constraint this creates\n *\n * **A decorator that wraps `guardedHandler` is now rejected**, even a\n * well-intentioned one that faithfully calls through. This is deliberate:\n * the combiner cannot verify that an arbitrary wrapper still invokes the\n * permission check, and \"trust the wrapper\" is precisely the assumption\n * that produced this finding. Wrap the `handler` you pass INTO\n * `defineAdminRoute` instead — the factory then gates YOUR wrapper, and\n * the gate it mints is the one that ends up in this set.\n *\n * ## Why `WeakSet` and why not exported\n *\n * `WeakSet` holds its members weakly, so an entry that goes out of scope\n * (a test fixture, a conditionally-built route) is collectable — this adds\n * no retention. Module-private and NOT re-exported from `src/index.ts`,\n * for exactly the reason `GATED` / `ENTRY_GATED` are not: an exported\n * `mintedGuards.add(mine)` would hand the forger the mint.\n */\nconst MINTED_GUARDS = new WeakSet<object>()\n\n/**\n * The compiled output of {@link defineAdminRoute}. The handler is wrapped\n * in a permission gate (`guardedHandler`); the original is preserved as\n * `handler` for tests + introspection.\n *\n * **Not constructible by hand, and not re-pointable after the fact.** Three\n * things enforce that, and each covers a hole the others do not:\n *\n * 1. The {@link ENTRY_GATED} brand can only be named inside this package,\n * so a hand-written literal is a COMPILE error.\n * 2. Every field is `readonly`, so `entry.guardedHandler = mine` is a\n * COMPILE error too — the plain-mutation form of the same attack.\n * 3. `MINTED_GUARDS` records the `guardedHandler` function identity\n * at mint time, and {@link combineAdminRoutes} requires it at RUNTIME.\n * This is the only layer that catches `{ ...entry, guardedHandler:\n * mine }` — a fresh literal, so `readonly` never applies to it, and a\n * faithful spread, so the brand comes along for the ride.\n *\n * `defineAdminRoute` — which requires a `permission`, registers it in the\n * catalog, and builds `guardedHandler` itself — is therefore the only way\n * to produce a value that survives all three. Declare the entry's TYPE\n * (`function xRouteEntries(): AdminRouteEntry[]`) freely; just build the\n * values with the factory.\n *\n * Generic over `TApp` (see the `TApp` threading note above) — the\n * registered `path` here is the plain compiled string, no longer\n * constrained by {@link SegmentPath} (that constraint applies only at\n * {@link defineAdminRoute}'s call site).\n */\nexport interface AdminRouteEntry<TApp = unknown> {\n /**\n * Nominal brand — present ONLY on entries minted by\n * {@link defineAdminRoute}. See {@link ENTRY_GATED} for the full\n * rationale; the short version is that this property is what stops a\n * hand-built entry literal from laundering an ungated handler through\n * `combineAdminRoutes` and out the other side as a branded\n * {@link AdminRoute}. It does NOT, on its own, stop a *copy* of a real\n * entry with a substituted gate — that is `MINTED_GUARDS`'s job.\n */\n readonly [ENTRY_GATED]: true\n readonly prefix: string\n readonly path: string\n readonly method: AdminRouteMethod\n readonly permission: string\n /** Parsed left side of `permission`. */\n readonly resource: string\n /** Parsed right side of `permission`. */\n readonly action: string\n readonly defaultRoles: readonly string[]\n readonly description: string | undefined\n /** Original user handler (unwrapped). */\n readonly handler: AdminRouteHandler<TApp>\n /**\n * Permission-gated handler. Returns 403 if `ctx.checkPermission(resource,\n * action)` is false; otherwise delegates to {@link handler}.\n *\n * On denial, emits a `permission.denied` audit entry via `ctx.audit`\n * (when available) BEFORE the 403 response — no per-route audit wiring\n * needed. The entry's metadata carries the permission string, caller\n * role, HTTP method, route coordinates, and request segments.\n *\n * **This function's IDENTITY is the provenance anchor.** It is recorded\n * in `MINTED_GUARDS` at mint time and re-checked by\n * {@link combineAdminRoutes}. Replacing it — by assignment (blocked by\n * `readonly`) or by spreading into a new literal (blocked at runtime) —\n * is refused, INCLUDING by a decorator that faithfully calls through.\n * To add behaviour around a route, wrap the `handler` you pass into\n * {@link defineAdminRoute}.\n */\n readonly guardedHandler: AdminRouteHandler<TApp>\n /**\n * Whether this entry catches every unmatched sub-path under\n * `(prefix, method)`. Mirrors {@link DefineAdminRouteSpec.matchAnyPath}\n * — captured on the compiled entry so `combineAdminRoutes` can wire\n * the dispatcher's catch-all slot.\n */\n readonly matchAnyPath: boolean\n}\n\n/**\n * One entry per `(resource, action)` pair contributed by `defineAdminRoute`,\n * `defineFeature`, or `defineAdminPage` (forthcoming). Consumed by:\n *\n * - `upsertBuiltInRoles` to seed default grants\n * - Permission Matrix UI (PR-D)\n * - `buildResourceCatalog` migration (PR-F)\n */\nexport interface PermissionCatalogEntry {\n resource: string\n action: string\n /** `'<resource>:<action>'` — the full permission string. */\n permission: string\n /** Roles that get this grant on a fresh upsert. */\n defaultRoles: readonly string[]\n /** Free-text description; surfaced in the audit UI. */\n description: string | undefined\n /**\n * Where this entry came from. `'route'` = `defineAdminRoute`,\n * `'page'` = `defineAdminPage`, `'feature'` = `defineFeature`. The\n * Matrix UI groups by this when rendering.\n */\n source: 'route' | 'page' | 'feature'\n}\n\n// ---------------------------------------------------------------------------\n// Catalog — process-local, accumulated as factories are called\n// ---------------------------------------------------------------------------\n\n/**\n * Process-local registry of every permission contributed by a factory call.\n *\n * Keyed on the full `'<resource>:<action>'` permission string. Later calls\n * for the SAME permission union their `defaultRoles` (so two routes that\n * both gate on `'ticket:view'` produce a single catalog entry with the\n * combined defaults).\n *\n * The registry is module-local — every package that imports this file\n * shares the SAME map instance via the bundler's module cache. The\n * Turbopack-duplication risk is acknowledged: if a future bundle config\n * loads `@murumets-ee/core` twice, the two copies would maintain\n * independent catalogs. That's the same risk every `@murumets-ee/*`\n * singleton already lives with (auth `_app`, etc.); the workspace\n * boundary script + tsdown's `neverBundle` rules already guard against\n * accidental duplication.\n */\nconst CATALOG = new Map<string, PermissionCatalogEntry>()\n\n/**\n * Register a permission in the catalog. Idempotent — repeat calls for the\n * same permission union the `defaultRoles`. Exported for factory code only\n * (`defineAdminRoute` / `defineFeature` / forthcoming `defineAdminPage`)\n * to share the same map; plugin code should call a factory instead — the\n * factories validate input shape (no `:` in resource/action, non-empty\n * fields, etc.) that this raw primitive does not.\n *\n * **Returns the resulting catalog entry.** On a fresh registration this is\n * the input `entry`; on a repeat registration this is the post-merge entry\n * (existing first-wins `source` + `description`, unioned `defaultRoles`).\n * Callers that need to expose the authoritative catalog state should use\n * the return value rather than the input draft — see `defineFeature`.\n */\nexport function registerPermission(entry: PermissionCatalogEntry): PermissionCatalogEntry {\n const existing = CATALOG.get(entry.permission)\n if (existing) {\n // Union default roles. Description: first NON-UNDEFINED wins — so a\n // route registered without a description doesn't shadow a description\n // contributed by a later registration of the same permission. The\n // call-site stability win is that the first call to set a description\n // is the authoritative one, but undefined slots get filled in later.\n const mergedRoles = new Set([...existing.defaultRoles, ...entry.defaultRoles])\n const merged: PermissionCatalogEntry = {\n ...existing,\n defaultRoles: [...mergedRoles],\n description: existing.description ?? entry.description,\n }\n CATALOG.set(entry.permission, merged)\n return merged\n }\n // Defensive clone of `defaultRoles` on fresh registration: callers\n // own the input object, and a mutation of `entry.defaultRoles` after\n // this call would otherwise mutate catalog state silently. The merge\n // branch above already constructs a fresh array via `[...mergedRoles]`,\n // so it's safe by construction. Mirror that here.\n const stored: PermissionCatalogEntry = { ...entry, defaultRoles: [...entry.defaultRoles] }\n CATALOG.set(entry.permission, stored)\n return stored\n}\n\n/**\n * Snapshot of the current catalog as a `Map<permission, entry>`.\n *\n * Returns a NEW map each call — callers can mutate the result without\n * affecting the registry. Read-only access to the live map is intentional\n * — the registry's invariants (e.g. unioned defaults) belong to\n * `registerPermission`, not to call sites.\n */\nexport function getPermissionCatalog(): Map<string, PermissionCatalogEntry> {\n return new Map(CATALOG)\n}\n\n/**\n * Test-only — clear the catalog between test suites that exercise factory\n * registration. NEVER call in production code; the catalog is global state\n * by design.\n */\nexport function _resetPermissionCatalog(): void {\n CATALOG.clear()\n}\n\n/**\n * Remove every catalog entry with `source: 'feature'`. Used by\n * `resolveShell` to rebuild the feature subset deterministically on every\n * call — features come exclusively from `Plugin.shared.features` in the\n * input plugin list, so the catalog should reflect the CURRENT input,\n * not the accumulated history.\n *\n * **Lifecycle contract:** `resolveShell` owns `source: 'feature'`\n * registrations. Direct `defineFeature` callers (tests, scripts) that\n * also call `resolveShell` will have their direct registrations cleared\n * when `resolveShell` next runs — same as `defineAdminRoute` entries\n * registered at module load aren't owned by `resolveShell` but features\n * registered IN this call are.\n *\n * Test-only direct callers that need their features to survive a\n * subsequent `resolveShell` either (a) re-call `defineFeature` after\n * `resolveShell`, or (b) declare them via a fixture plugin in the\n * `resolveShell` input.\n */\nexport function clearFeaturePermissions(): void {\n for (const [permission, entry] of CATALOG) {\n if (entry.source === 'feature') {\n CATALOG.delete(permission)\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// defineAdminRoute\n// ---------------------------------------------------------------------------\n\n/**\n * Parse a `'<resource>:<action>'` permission string into its parts.\n *\n * Permission strings may contain DOTS in the resource (e.g.\n * `'ticketing.chat:write'`) but exactly ONE colon. Two-or-more colons\n * (e.g. `'foo:bar:baz'`) are rejected loud — better to fail at\n * registration than to silently treat the first segment as the resource\n * and the rest as the action.\n */\nfunction parsePermission(permission: string): { resource: string; action: string } {\n const firstColon = permission.indexOf(':')\n const lastColon = permission.lastIndexOf(':')\n if (firstColon <= 0 || lastColon === permission.length - 1 || firstColon !== lastColon) {\n throw new Error(\n `defineAdminRoute: malformed permission '${permission}' — expected '<resource>:<action>' with exactly one colon`,\n )\n }\n return {\n resource: permission.slice(0, firstColon),\n action: permission.slice(firstColon + 1),\n }\n}\n\n// ---------------------------------------------------------------------------\n// Shared audit + denial primitives\n// ---------------------------------------------------------------------------\n//\n// The wrapper's `guardedHandler` uses these. Inline-gated routes that haven't\n// migrated to `defineAdminRoute` yet (search, commerce parts-search, commerce\n// register's create-guard) ALSO call these — directly, by the same name —\n// instead of hand-replicating the audit shape + try/catch + response shape.\n//\n// Why these are exported, not private: PR-A's whole point is \"no developer\n// has to remember the denial contract — the factory handles it.\" For routes\n// already migrated to `defineAdminRoute`, the wrapper handles it. For routes\n// pending migration (PR-C-rest), the inline pattern is `if (!check) {\n// emitPermissionDenied(...); return permissionDeniedResponse(...) }` — three\n// lines, no contract to forget. When those routes eventually migrate, the\n// calls just delete cleanly.\n//\n// History: this PR shipped THREE iterations of the same bug class —\n// CodeRabbit caught inline sites that forgot the try/catch (round 2) and the\n// audit shape (round 1). The root cause was hand-replication of the wrapper.\n// Extracting these helpers makes the replication mechanical and the bug class\n// structurally impossible: you can't forget what you don't write.\n\n/**\n * **The canonical primitive** for emitting audit entries from a route handler.\n *\n * `ctx.audit?.(...)` directly is a footgun: a synchronous throw inside the\n * audit adapter (bad serialization, malformed metadata, broken logger) would\n * turn the intended response into a 500. This wrapper swallows sync throws —\n * the response always lands.\n *\n * **Every audit emission from a route handler should go through this primitive\n * instead of calling `ctx.audit?.(...)` directly.** That removes the\n * \"did the developer remember to add try/catch?\" question from every call\n * site — the safe primitive IS the API.\n *\n * Sync-throw isolation only. Async-rejection handling for the `void`-typed\n * `AuditLogFn` happens centrally in `@murumets-ee/admin-ui`'s\n * `buildAuditLogFn` (issue #373) — the adapter that fronts the actual audit\n * logger is responsible for catching `Promise<void>` rejection.\n *\n * @example\n * ```ts\n * import { safeAudit } from '@murumets-ee/core'\n *\n * if (somethingFailed) {\n * safeAudit(ctx, {\n * action: 'commerce.import.run.rejected',\n * entityType: 'import_run',\n * userId: ctx.user.id,\n * metadata: { reason: 'size mismatch', storageKey },\n * })\n * return errorJson('...', 400)\n * }\n * ```\n */\nexport function safeAudit(\n ctx: Parameters<AdminRouteHandler>[1],\n entry: Parameters<NonNullable<Parameters<AdminRouteHandler>[1]['audit']>>[0],\n): void {\n try {\n ctx.audit?.(entry)\n } catch {\n // Swallow — audit logger failures must not break the response.\n }\n}\n\n/**\n * Spec for {@link emitPermissionDenied}. Mirrors the subset of\n * {@link DefineAdminRouteSpec} that's relevant to audit metadata. Inline-gated\n * routes supply this manually; the wrapper builds it from its own spec.\n */\nexport interface PermissionDenialContext {\n /** The permission that was denied, in `'<resource>:<action>'` form. */\n permission: string\n /** HTTP method of the request. */\n method: AdminRouteMethod\n /** URL prefix segment (the `prefix` field of the route's `defineAdminRoute` spec). */\n prefix: string\n /** Path within the prefix (the `path` field; `''` for prefix-root). */\n path: string\n /**\n * Extra audit metadata merged into the persisted entry's `metadata` block.\n *\n * Use sparingly — most permission-denial events have everything they need\n * from the standard fields. The two known consumers as of writing:\n *\n * - `kind: 'dispatch-miss'` from `makeDispatcher` so audit-search can\n * distinguish wrapper-gated 403s (caller hit a registered route they\n * can't access) from dispatcher-miss 403s (caller probed for an\n * unknown sub-path under a prefix they have zero visibility into).\n * Wrapper-gated emits don't set `kind`; absence = wrapper-gated.\n * - `requiredAny: string[]` from `makeDispatcher` listing the full\n * prefix permission set the caller lacks (vs. the single\n * representative `permission` field). Lets forensics see whether\n * the caller was missing one specific grant or all of them.\n *\n * Caller-supplied keys override standard keys on collision. Out-of-band\n * `userId`/`userName` etc. are not allowed via this path (they live on\n * the top-level audit entry, not in `metadata`).\n */\n extraMetadata?: Record<string, unknown>\n}\n\n/**\n * Emit the standard `permission.denied` audit entry for a denied request.\n *\n * Sync-throw isolated: a failing audit adapter MUST NOT turn the intended 403\n * into a 500. Async-rejection handling for the `void`-typed `AuditLogFn` lives\n * separately in `@murumets-ee/admin-ui`'s `buildAuditLogFn` (#373).\n *\n * The audit shape is filterable by `action = 'permission.denied'`, with\n * structured metadata for forensics (permission, role, HTTP method, route\n * coordinates, request segments). `userName` and `role` are conditionally\n * spread so undefined values don't pollute the persisted metadata.\n *\n * `entityType: 'permission'` (singular) is intentional — it describes the\n * abstract grant being denied. Compare with `@murumets-ee/auth`'s permission-\n * management routes which use `entityType: 'permissions'` (plural) for\n * actions like `permissions.update` / `permissions.role.create`. The semantic\n * split: `permission` = \"denial event\"; `permissions` = \"role-management\n * resource being CRUD'd\". Audit-search filters targeting either category\n * should query both when looking for any permission-related activity.\n */\nexport function emitPermissionDenied(\n ctx: Parameters<AdminRouteHandler>[1],\n spec: PermissionDenialContext,\n): void {\n // Delegate to safeAudit — the canonical try/catch-isolated primitive.\n // No bespoke try/catch here; sync-throw safety is the primitive's job.\n safeAudit(ctx, {\n action: 'permission.denied',\n entityType: 'permission',\n userId: ctx.user.id,\n ...(ctx.user.name !== undefined && { userName: ctx.user.name }),\n metadata: {\n permission: spec.permission,\n ...(ctx.user.role !== undefined && { role: ctx.user.role }),\n method: spec.method,\n prefix: spec.prefix,\n path: spec.path,\n segments: ctx.segments,\n ...spec.extraMetadata,\n },\n })\n}\n\n/**\n * The standard 403 response for permission denial. Body shape:\n * `{ error, code: 'forbidden' }`. Frontend callers can branch on\n * `body.code === 'forbidden'` uniformly across every admin denial.\n *\n * Pair with {@link emitPermissionDenied} at every inline permission check:\n *\n * ```ts\n * if (!ctx.checkPermission(resource, action)) {\n * emitPermissionDenied(ctx, { permission: `${resource}:${action}`, method, prefix, path })\n * return permissionDeniedResponse(`${resource}:${action}`, ctx.user.role)\n * }\n * ```\n */\nexport function permissionDeniedResponse(permission: string, role: string | undefined): Response {\n return new Response(\n JSON.stringify({\n error: `Forbidden: role '${role ?? '(unknown)'}' lacks permission '${permission}'`,\n code: 'forbidden',\n }),\n { status: 403, headers: { 'content-type': 'application/json' } },\n )\n}\n\n/**\n * Declare an admin API route with its required permission.\n *\n * The returned {@link AdminRouteEntry} is consumed by\n * {@link combineAdminRoutes}, which collapses many entries into the\n * legacy `AdminRoute[]` shape that `createAdminApiHandler` already\n * dispatches.\n *\n * **Side effect:** registers the permission in the process-local\n * catalog. Calling the factory at module-load time (the normal usage,\n * inside a `routes/*.ts` file) means the catalog is fully populated by\n * the time the api-handler boots.\n *\n * **Auto-audit:** when the wrapper denies a request (the caller's role\n * lacks `permission`), it emits a `permission.denied` entry via\n * `ctx.audit` before returning 403. Metadata includes the permission\n * string, caller role, HTTP method, route coordinates, and segments.\n * Every denial leaves a trail without per-route boilerplate; the prior\n * pattern relied on inline `auditRejection`-style helpers that were\n * forgotten on most routes (search, parts-search, taxonomy, etc.),\n * making UUID-enumeration probes / permission-bypass attempts invisible\n * post-hoc. Filter the audit search on `action = 'permission.denied'`.\n *\n * @example\n * ```ts\n * import { defineAdminRoute } from '@murumets-ee/core'\n *\n * export const replyRoute = defineAdminRoute({\n * prefix: 'ticketing',\n * path: 'reply',\n * method: 'POST',\n * permission: 'ticket:update',\n * defaultRoles: ['admin', 'agent'],\n * description: 'Post an agent reply to a ticket conversation',\n * handler: async (req, ctx) => { return new Response('ok') },\n * })\n * ```\n */\nexport function defineAdminRoute<TApp = unknown, const P extends string = string>(\n spec: DefineAdminRouteSpec<TApp, P>,\n): AdminRouteEntry<TApp> {\n // Defense-in-depth: the type-level `SegmentPath<P>` constraint\n // (PR 5 of PLAN-DECLARATIVE-PLUGINS) rejects multi-segment paths at\n // compile time. This runtime check survives so a malformed dist that\n // slipped past TS, or a callsite that escaped via `as any` / `as\n // string`, still fails loud-at-boot rather than landing in the\n // dispatcher's segments[0]-keyed map and silently 404ing every\n // request.\n if (spec.path.includes('/')) {\n throw new Error(\n `defineAdminRoute: path '${spec.path}' contains '/' — only single-segment paths are dispatched by combineAdminRoutes. Read trailing segments from ctx.segments[1..] inside the handler instead.`,\n )\n }\n const matchAnyPath = spec.matchAnyPath ?? false\n if (matchAnyPath && spec.path !== '') {\n // Catch-all entries are reserved for the prefix-root path slot.\n // A non-empty `path` would mean \"match this exact segment AND any\n // unmatched sub-path\" — which is ambiguous (does it claim the\n // segment or does it claim the catch-all?). Reject at registration\n // so the contract is one-shape.\n throw new Error(\n `defineAdminRoute: matchAnyPath requires path: '' (got '${spec.path}' for '${spec.method} ${spec.prefix}'). Catch-all entries claim the prefix-root + every unmatched sub-path; static paths take precedence.`,\n )\n }\n const { resource, action } = parsePermission(spec.permission)\n const defaultRoles = spec.defaultRoles ?? []\n\n registerPermission({\n resource,\n action,\n permission: spec.permission,\n defaultRoles,\n description: spec.description,\n source: 'route',\n })\n\n const guardedHandler: AdminRouteHandler<TApp> = async (req, ctx) => {\n if (!ctx.checkPermission(resource, action)) {\n // Delegate to the shared denial primitives — same code path the\n // pending-migration inline-gated routes (search, commerce parts-\n // search, commerce register create-guard) call. Single source of\n // truth for the audit shape, the sync-throw isolation, and the\n // body shape. Every contract guarantee comes from these helpers.\n emitPermissionDenied(ctx, {\n permission: spec.permission,\n method: spec.method,\n prefix: spec.prefix,\n path: spec.path,\n })\n return permissionDeniedResponse(spec.permission, ctx.user.role)\n }\n return spec.handler(req, ctx)\n }\n\n // THE SOLE MINT SITE of the gate's runtime provenance (finding H1 —\n // residual). Registered on the FUNCTION, not on the entry object,\n // because the object is copyable and the function reference is not:\n // `{ ...entry, guardedHandler: mine }` carries the entry brand\n // faithfully but lands a different function here, and\n // `combineAdminRoutes` refuses it. See `MINTED_GUARDS`.\n MINTED_GUARDS.add(guardedHandler)\n\n return {\n // THE SOLE MINT SITE of the entry brand (`plan/admin-api-hardening`\n // PR 3, finding H1). Reaching this line means `permission` was present\n // and parsed, the catalog registration happened, and `guardedHandler`\n // wraps the user's handler in the 403 gate — so the brand is the\n // assertion \"this entry carries a real permission gate\".\n //\n // Named directly (no cast) because `ENTRY_GATED` is module-visible\n // here, and the literal is checked against `AdminRouteEntry<TApp>` in\n // full — a future field added to the interface still fails this\n // return rather than being silently cast away.\n //\n // Plain literal assignment, NOT `Object.defineProperty(..., {\n // enumerable: false })`, for the same reason as `GATED`: the brand\n // must survive `{ ...entry }` spread or `combineAdminRoutes` would\n // reject an entry the compiler blessed.\n //\n // That spread-survival is also precisely why the ENVELOPE is the\n // wrong provenance anchor — a spread copy keeps the brand while\n // swapping `guardedHandler` for an ungated function, which\n // typechecks with no cast at all. `MINTED_GUARDS` (registered just\n // above, keyed on the gate's function identity) is what covers\n // that; this brand's remaining job is the COMPILE-time rejection of\n // a hand-written literal, which a WeakSet cannot do.\n [ENTRY_GATED]: true,\n prefix: spec.prefix,\n path: spec.path,\n method: spec.method,\n permission: spec.permission,\n resource,\n action,\n defaultRoles,\n description: spec.description,\n handler: spec.handler,\n guardedHandler,\n matchAnyPath,\n }\n}\n\n// ---------------------------------------------------------------------------\n// defineFeature — register a non-CRUD plugin resource in the catalog\n// ---------------------------------------------------------------------------\n\n/**\n * Spec passed to {@link defineFeature}.\n *\n * Plugin-level NON-CRUD resources whose permission grants aren't tied to a\n * single route or entity — e.g. `ticketing.bulk-edit` with actions\n * `['view', 'execute']`, or `commerce.imports` with `['view', 'export',\n * 'cancel']`. Use `defineAdminRoute` for routes and `defineAdminPage` for\n * pages; `defineFeature` is the catalog hook for everything that's neither.\n *\n * The resource string is the same shape as a `defineAdminRoute` permission's\n * left side. Actions are the right side — one catalog entry is emitted per\n * `(resource, action)` pair, exactly as if `defineAdminRoute` had been\n * called once per action with a no-op handler.\n *\n * @example\n * ```ts\n * defineFeature({\n * resource: 'ticketing.bulk-edit',\n * actions: ['view', 'execute'],\n * defaultRoles: ['admin', 'agent'],\n * description: 'Operate ticketing bulk-edit tools',\n * })\n * ```\n */\nexport interface DefineFeatureSpec {\n /**\n * Resource string — left side of the permission. Must be non-empty and\n * not contain `:` (the action separator). Convention: dot-notation for\n * plugin-prefixed names (`'ticketing.bulk-edit'`, `'commerce.imports'`).\n */\n resource: string\n /**\n * Actions on this resource. Each becomes a catalog entry\n * `<resource>:<action>`. Must be non-empty; each action must be a\n * non-empty string without `:`. Duplicates within the same call are\n * rejected (would be silently deduped by `registerPermission` but the\n * call-site shape suggests programmer error).\n */\n actions: readonly string[]\n /**\n * Built-in roles that should be granted EVERY action on this resource\n * on first deploy / on `upsertBuiltInRoles`. Defaults to `[]`. The same\n * default-roles set applies to every action — if different actions\n * need different default grants, register separate `defineFeature`\n * calls (or use `defineAdminRoute` for the action that needs a\n * different grant).\n */\n defaultRoles?: readonly string[]\n /**\n * Optional description for the Permission Matrix UI (PR-D) and audit\n * logs. Plain-text, no markdown. Applied to every catalog entry the\n * call produces; per-action descriptions need separate `defineFeature`\n * calls (one per action).\n */\n description?: string\n}\n\n/**\n * Register a non-CRUD plugin resource in the permission catalog.\n *\n * **Plugin authors should NOT call this directly.** Contribute features\n * declaratively via `Plugin.shared.features: DefineFeatureSpec[]` — the\n * framework's merge step at boot iterates that array and calls this\n * factory for each spec. The declarative path is the supported plugin-\n * authoring API; direct invocation is reserved for tests / scripts /\n * non-plugin call sites that need to populate the catalog explicitly.\n *\n * The returned `PermissionCatalogEntry[]` contains ONE entry per `(resource,\n * action)` pair — the same shape every other catalog consumer\n * (`upsertBuiltInRoles`, Permission Matrix UI, `buildResourceCatalog`)\n * already understands. Each returned entry reflects the AUTHORITATIVE\n * post-merge catalog state: on a fresh registration it equals the input\n * spec's fields; on a repeat registration (something else already\n * contributed the same `(resource, action)` pair) the entry carries the\n * first registrant's `source` + `description` and the union of every\n * registrant's `defaultRoles`. Callers consume the return value as a\n * truthful snapshot, not the draft this call would have registered.\n *\n * Unlike {@link defineAdminRoute}, `defineFeature` does NOT produce a\n * handler — its only contribution is the catalog registration. Use this\n * for non-route, non-entity-action permissions that the toolkit's auth\n * layer should know about: feature flags an admin can grant per-role,\n * bulk operations that aren't a discrete HTTP endpoint, scheduled-job\n * \"run-now\" capabilities, etc.\n *\n * **Why a wrapper exists when `registerPermission` already does the job:**\n * the wrapper validates input shape (non-empty resource, non-empty\n * actions, no `:` in either, no duplicate actions in the same call),\n * splits a multi-action declaration into the right number of catalog\n * entries, and sets `source: 'feature'` so the Matrix UI can group by\n * declaration site. `registerPermission` is the lower-level primitive\n * shared with `defineAdminRoute`; plugin code should reach for\n * `defineFeature` instead.\n *\n * **Idempotency:** repeat calls for the same `(resource, action)` pair\n * union their `defaultRoles` (same behavior as `defineAdminRoute`\n * registering the same permission twice). Two plugins both declaring\n * `'ticketing.bulk-edit:execute'` produce one catalog entry with the\n * combined defaults.\n *\n * @example Plugin authoring (declarative — preferred):\n * ```ts\n * import type { Plugin } from '@murumets-ee/core'\n *\n * export function ticketingPlugin(): Plugin {\n * return {\n * name: '@app/ticketing',\n * shared: {\n * features: [\n * {\n * resource: 'ticketing.bulk-edit',\n * actions: ['view', 'execute'],\n * defaultRoles: ['admin', 'agent'],\n * description: 'Operate ticketing bulk-edit tools',\n * },\n * ],\n * },\n * }\n * }\n * ```\n *\n * @example Tests / scripts (direct invocation):\n * ```ts\n * import { defineFeature } from '@murumets-ee/core'\n *\n * const entries = defineFeature({\n * resource: 'ticketing.bulk-edit',\n * actions: ['view', 'execute'],\n * defaultRoles: ['admin', 'agent'],\n * })\n * // entries[0].source === 'feature'\n * ```\n */\nexport function defineFeature(spec: DefineFeatureSpec): PermissionCatalogEntry[] {\n // Reject empty AND whitespace-padded resource. Storing `'ops.dashboard '`\n // (trailing space) verbatim would produce a catalog key\n // `'ops.dashboard :view'` distinct from the obviously-equivalent\n // `'ops.dashboard:view'` — silent duplicate. Loud-reject at boot per\n // the strict-input policy; callers that meant the trimmed value pass it\n // explicitly.\n if (spec.resource.length === 0) {\n throw new Error(\"defineFeature: 'resource' must be a non-empty string\")\n }\n if (spec.resource.trim().length === 0) {\n throw new Error(\n `defineFeature: 'resource' must not be whitespace-only (got '${spec.resource}')`,\n )\n }\n if (spec.resource !== spec.resource.trim()) {\n throw new Error(\n `defineFeature: 'resource' '${spec.resource}' has leading/trailing whitespace — would silently produce a duplicate catalog key vs. the trimmed form. Pass '${spec.resource.trim()}' instead.`,\n )\n }\n if (spec.resource.includes(':')) {\n throw new Error(\n `defineFeature: 'resource' '${spec.resource}' must not contain ':' — that's the action separator. Use dot-notation for namespacing (e.g. 'ticketing.bulk-edit').`,\n )\n }\n if (spec.actions.length === 0) {\n throw new Error(\n `defineFeature: 'actions' for resource '${spec.resource}' must be a non-empty array — declare at least one action`,\n )\n }\n // Reject duplicates AT THE CALL SITE — `registerPermission` would\n // silently union the duplicate's defaultRoles, but that hides what's\n // almost certainly programmer error (typo, copy-paste). Loud at\n // registration matches the rest of the factory's strict-input policy.\n // Same trim-mismatch rejection as the resource above: `'view '` and\n // `'view'` must not coexist; the catalog key is the exact string.\n const seen = new Set<string>()\n for (const action of spec.actions) {\n if (action.length === 0) {\n throw new Error(\n `defineFeature: action in resource '${spec.resource}' must be a non-empty string`,\n )\n }\n if (action.trim().length === 0) {\n throw new Error(\n `defineFeature: action in resource '${spec.resource}' must not be whitespace-only (got '${action}')`,\n )\n }\n if (action !== action.trim()) {\n throw new Error(\n `defineFeature: action '${action}' for resource '${spec.resource}' has leading/trailing whitespace — would silently produce a duplicate catalog key vs. the trimmed form. Pass '${action.trim()}' instead.`,\n )\n }\n if (action.includes(':')) {\n throw new Error(\n `defineFeature: action '${action}' for resource '${spec.resource}' must not contain ':' — the catalog stores '<resource>:<action>' so a colon inside the action is ambiguous`,\n )\n }\n if (seen.has(action)) {\n throw new Error(\n `defineFeature: duplicate action '${action}' for resource '${spec.resource}' — each action must be unique within a single defineFeature call`,\n )\n }\n seen.add(action)\n }\n const defaultRoles = spec.defaultRoles ?? []\n const entries: PermissionCatalogEntry[] = []\n for (const action of spec.actions) {\n const draft: PermissionCatalogEntry = {\n resource: spec.resource,\n action,\n permission: `${spec.resource}:${action}`,\n defaultRoles,\n description: spec.description,\n source: 'feature',\n }\n // Push the AUTHORITATIVE post-merge entry, not the draft. When this\n // call's (resource, action) was already registered (by a prior\n // defineFeature, defineAdminRoute, or future defineAdminPage), the\n // catalog keeps the FIRST registrant's `source` + `description` and\n // unions defaultRoles. The draft would diverge from that state\n // (claiming `source: 'feature'` even when an earlier route call won\n // `source: 'route'`), which would be a lie callers consume.\n entries.push(registerPermission(draft))\n }\n return entries\n}\n\n// ---------------------------------------------------------------------------\n// combineAdminRoutes — adapter to legacy AdminRoute[] shape\n// ---------------------------------------------------------------------------\n\n/**\n * Read a best-effort `prefix` off an untrusted value, for error messages.\n *\n * The input to {@link combineAdminRoutes} is TYPED as `AdminRouteEntry[]`,\n * but the whole point of the runtime check is that types are erased — a\n * plain-JS caller or an `as` cast can hand over literally anything. So the\n * prefix is read defensively rather than as `entry.prefix`.\n */\nfunction readablePrefix(value: unknown): string {\n if (typeof value === 'object' && value !== null && 'prefix' in value) {\n const prefix = value.prefix\n if (typeof prefix === 'string' && prefix.length > 0) return prefix\n }\n return '(unreadable prefix)'\n}\n\n/**\n * Whether `value` carries the {@link ENTRY_GATED} brand — i.e. whether the\n * ENVELOPE looks like something {@link defineAdminRoute} produced.\n *\n * Necessary but NOT sufficient: a spread copy carries the brand faithfully\n * while its `guardedHandler` may have been replaced. Pair every call with\n * {@link hasMintedGuard}, which checks the thing that actually holds the\n * permission gate.\n *\n * Module-private on purpose. Unlike `isGatedRoute` (which route collection\n * in `@murumets-ee/admin-ui` genuinely needs), nothing outside this file\n * consumes raw entries — {@link combineAdminRoutes} is the only gate they\n * pass through, and it does the checking.\n */\nfunction isGatedEntry(value: unknown): boolean {\n return (\n typeof value === 'object' &&\n value !== null &&\n ENTRY_GATED in value &&\n value[ENTRY_GATED] === true\n )\n}\n\n/**\n * Whether `value.guardedHandler` is a gate {@link defineAdminRoute} actually\n * minted, as opposed to any other function occupying that slot.\n *\n * This is the check {@link isGatedEntry} cannot make. See\n * {@link MINTED_GUARDS} for why function identity — not the entry object —\n * is the only anchor a copy cannot launder.\n *\n * `value` is `unknown` rather than `AdminRouteEntry` for the same reason\n * {@link readablePrefix}'s is: the runtime check exists precisely because\n * the static type is a claim, not a fact.\n */\nfunction hasMintedGuard(value: unknown): boolean {\n if (typeof value !== 'object' || value === null) return false\n if (!('guardedHandler' in value)) return false\n const guard = value.guardedHandler\n // `WeakSet.prototype.has` is spec'd to return false (not throw) for a\n // non-object, but TypeScript wants the narrowing, and a non-function in\n // this slot is a forgery signal in its own right.\n return typeof guard === 'function' && MINTED_GUARDS.has(guard)\n}\n\n/**\n * Collapse a list of `defineAdminRoute` outputs into the legacy\n * `AdminRoute[]` shape that `createAdminApiHandler` dispatches.\n *\n * Routes are grouped by `prefix`. Each prefix's resulting `AdminRoute`\n * gets per-method handlers that dispatch on the leading path segment\n * matching the entry's `path` (or the root segment when `path === ''`).\n *\n * Sub-path dispatch is strictly single-level — `path` cannot contain\n * `/` (rejected by `defineAdminRoute` at registration). Dynamic IDs are\n * read by the handler from `ctx.segments[1..]`. This matches the\n * existing convention every plugin already uses (see ticketing's\n * `dispatch()` for the prior art).\n *\n * Conflict detection: two entries claiming the same `(prefix, method,\n * path)` triple throw at combine time — better to fail at boot than to\n * silently shadow a route.\n *\n * Provenance check, in two parts (finding H1 and its residual):\n *\n * 1. The entry must carry the {@link ENTRY_GATED} brand — it must LOOK\n * like something {@link defineAdminRoute} produced.\n * 2. Its `guardedHandler` must be one {@link defineAdminRoute} actually\n * minted (`MINTED_GUARDS`). Part 1 alone is insufficient: a\n * spread copy carries the brand while substituting the gate.\n *\n * Either failure throws. **A decorator that wraps `guardedHandler` fails\n * part 2 and is refused** — intentionally, since a wrapper's continued\n * gating cannot be verified from here. Wrap the `handler` passed into\n * {@link defineAdminRoute} instead.\n */\nexport function combineAdminRoutes<TApp = unknown>(\n entries: readonly AdminRouteEntry<TApp>[],\n): AdminRoute<TApp>[] {\n const byPrefix = new Map<string, AdminRouteEntry<TApp>[]>()\n for (const [index, entry] of entries.entries()) {\n // Provenance gate — runs BEFORE anything is bucketed, so an unbranded\n // entry cannot contribute a handler or a prefix-permission to the\n // route this function is about to mint.\n //\n // THROWING IS CORRECT HERE, and is NOT in tension with the deliberate\n // log-and-skip in `getRouteMap` (`@murumets-ee/admin-ui`). Do not\n // \"harmonise\" the two later: they run at different times and have\n // different blast radii. `combineAdminRoutes` runs at MODULE LOAD, in\n // a plugin's own `routes.ts` — a throw there fails that module's\n // import, loud, at boot, where an author sees it immediately.\n // `getRouteMap` runs on every process's FIRST DISPATCH; a throw there\n // would turn one plugin's config bug into a hard outage for the whole\n // admin API, so it logs the prefix and skips (an ungated route\n // degrades to a 404 rather than an open door).\n if (!isGatedEntry(entry)) {\n // TWO causes produce this, and they are indistinguishable from here\n // — the same dual-cause shape `assertGated` in `@murumets-ee/admin-ui`\n // documents (finding M1). Cause 2 matters more here than there,\n // because this throw crashes module load rather than degrading one\n // prefix to a 404, and because `src/index.ts`'s own docs bless the\n // package-A-mints / package-B-combines split that a duplicate\n // install breaks. Do NOT reduce this to \"you built it wrong\".\n throw new Error(\n `combineAdminRoutes: entry ${index} (prefix '${readablePrefix(entry)}') does not carry the provenance brand that defineAdminRoute() mints, so its handlers cannot be shown to enforce a permission check. TWO possible causes, identical from here: ` +\n `(1) the entry was hand-built as an AdminRouteEntry literal (or cast into one) — such an entry has no permission gate, no catalog registration and no audit-on-deny, yet would mint a fully-branded AdminRoute serving every request under its prefix; build it with defineAdminRoute({ prefix, path, method, permission, handler }) instead. ` +\n `(2) TWO copies of @murumets-ee/admin-route are installed, so the brand minted by one copy is unrecognisable to the other — one package minting entries and another combining them is a supported idiom, so this is NOT an authoring mistake. If the entry IS built with defineAdminRoute(), run \\`pnpm why @murumets-ee/admin-route\\` and dedupe before changing any code.`,\n )\n }\n // Part 2 — the gate itself, not the envelope. The brand above survives\n // `{ ...entry }` by design, so it says nothing about whether\n // `guardedHandler` is still the function the factory minted.\n if (!hasMintedGuard(entry)) {\n throw new Error(\n `combineAdminRoutes: entry ${index} (prefix '${readablePrefix(entry)}') carries the provenance brand, but its guardedHandler is not one defineAdminRoute() minted — the permission gate was substituted after the entry was created. ` +\n `That is what a decorator does: \\`{ ...entry, guardedHandler: wrapped }\\` (or \\`entry.guardedHandler = wrapped\\`) keeps the brand while discarding the gate. ` +\n `WRAPPING guardedHandler IS NOT SUPPORTED, even faithfully — the combiner cannot verify that an arbitrary wrapper still calls the permission check, and trusting it is exactly the assumption this refuses to make. To add behaviour around a route, wrap the \\`handler\\` you pass INTO defineAdminRoute({ ... }); the factory then gates your wrapper.`,\n )\n }\n const bucket = byPrefix.get(entry.prefix) ?? []\n bucket.push(entry)\n byPrefix.set(entry.prefix, bucket)\n }\n\n const routes: AdminRoute<TApp>[] = []\n for (const [prefix, group] of byPrefix) {\n // Per-method buckets split into an EXACT-match map (keyed on the\n // entry's static `path`) and an optional catch-all handler (the\n // single `matchAnyPath` entry, if registered). Exact matches take\n // precedence over the catch-all — registering both `path: ''`\n // (matchAnyPath) and `path: 'subaction'` (static) under the same\n // method gives `subaction` the dispatch, with the catch-all\n // claiming every other sub-path.\n const byMethod: Record<\n AdminRouteMethod,\n { exact: Map<string, AdminRouteHandler<TApp>>; catchAll: AdminRouteHandler<TApp> | null }\n > = {\n GET: { exact: new Map(), catchAll: null },\n POST: { exact: new Map(), catchAll: null },\n PATCH: { exact: new Map(), catchAll: null },\n DELETE: { exact: new Map(), catchAll: null },\n }\n for (const entry of group) {\n const bucket = byMethod[entry.method]\n if (entry.matchAnyPath) {\n if (bucket.catchAll !== null) {\n throw new Error(\n `combineAdminRoutes: duplicate matchAnyPath for '${entry.method} ${prefix}' — at most one matchAnyPath entry per (prefix, method)`,\n )\n }\n bucket.catchAll = entry.guardedHandler\n continue\n }\n if (bucket.exact.has(entry.path)) {\n throw new Error(\n `combineAdminRoutes: duplicate route '${entry.method} ${prefix}/${entry.path}' — each (prefix, method, path) must be unique`,\n )\n }\n bucket.exact.set(entry.path, entry.guardedHandler)\n }\n\n // Prefix-level permission summary — the union of (resource, action)\n // pairs across every entry at this prefix, regardless of method.\n // Used by `makeDispatcher` to decide whether a dispatch-miss should\n // return 404 (caller can see this prefix → honest) or 403 (caller\n // has zero visibility → don't leak that the prefix even exists).\n //\n // Computed ONCE per prefix at registration time, NOT per request.\n // Acceptance criterion #2 of issue #371.\n //\n // Deduped on the `<resource>:<action>` key so a prefix with the\n // same permission registered on multiple methods (e.g. GET `/` and\n // GET `/:id` both gated on `ticket:view`) doesn't carry duplicates\n // through `.some()` checks or into the `requiredAny` audit metadata.\n //\n // matchAnyPath entries contribute to this summary too — their\n // permission is still part of the prefix's grant set, used by\n // OTHER methods' dispatch-miss policy (e.g. POST dispatch-miss\n // visibility check) even though the matchAnyPath method itself\n // bypasses dispatch-miss entirely.\n const prefixPermsByKey = new Map<string, Pick<AdminRouteEntry, 'resource' | 'action'>>()\n for (const entry of group) {\n prefixPermsByKey.set(`${entry.resource}:${entry.action}`, {\n resource: entry.resource,\n action: entry.action,\n })\n }\n const prefixPermissions: ReadonlyArray<Pick<AdminRouteEntry, 'resource' | 'action'>> = [\n ...prefixPermsByKey.values(),\n ]\n\n const handlers: AdminRoute<TApp>['handlers'] = {}\n for (const method of ['GET', 'POST', 'PATCH', 'DELETE'] as const) {\n const { exact, catchAll } = byMethod[method]\n if (exact.size === 0 && catchAll === null) continue\n handlers[method] = makeDispatcher<TApp>(exact, catchAll, {\n prefix,\n method,\n prefixPermissions,\n })\n }\n\n routes.push({\n // THE SOLE MINT SITE of the gating brand (`plan/admin-api-hardening`\n // PR 3, SD002). Every entry in `group` came from `defineAdminRoute`,\n // which requires a `permission` and wraps the handler in\n // `guardedHandler` — so branding here is the assertion \"every handler\n // reachable through this route has already passed a permission check\".\n //\n // Named directly (no `as AdminRoute` cast) because `GATED` is\n // module-visible here: the object literal is checked against\n // `AdminRoute<TApp>` in full, so a future field added to the interface\n // still fails this line rather than being silently cast away.\n //\n // Plain literal assignment, NOT `Object.defineProperty(..., {\n // enumerable: false })` — the brand must survive `{ ...route }` spread\n // or `isGatedRoute` would reject a route the compiler blessed. Symbol\n // keys are already invisible to `Object.keys` / `for…in` /\n // `JSON.stringify`, so enumerability costs nothing.\n [GATED]: true,\n prefix,\n handlers,\n })\n }\n return routes\n}\n\n/**\n * Options for {@link makeDispatcher}. Carries the per-prefix metadata the\n * dispatcher needs to make a correct 403-vs-404 decision on a sub-path miss\n * (issue #371): the prefix string + method for audit emission, plus the\n * prefix-level permission summary for the visibility check.\n */\ninterface DispatcherOptions {\n prefix: string\n method: AdminRouteMethod\n prefixPermissions: ReadonlyArray<Pick<AdminRouteEntry, 'resource' | 'action'>>\n}\n\n/**\n * Build a per-method dispatcher that routes by the leading path segment.\n *\n * The handler context's `segments` array contains the path AFTER the prefix\n * was consumed, so `ctx.segments[0]` is the entry's `path` for single-level\n * routes. When `path === ''`, the dispatcher matches when `segments` is\n * empty (root of the prefix).\n *\n * **Dispatch-miss policy (issue #371):** when no registered handler matches\n * the requested sub-path, the dispatcher consults the prefix-level\n * permission summary:\n *\n * - Caller has ANY grant at this prefix (incl. admin via the hardcoded\n * safety net) → 404. \"No such route\" is honest because the caller\n * can already see this prefix's other routes.\n * - Caller has ZERO grants at this prefix → 403 + emit\n * `permission.denied` audit with the representative (first) prefix\n * permission. Hides whether the sub-path exists from callers who\n * shouldn't be able to enumerate the URL surface.\n *\n * The 403-on-miss branch uses the SAME helpers as the per-route\n * `guardedHandler` so audit-search filters on `action = 'permission.denied'`\n * surface dispatcher-miss denials uniformly with matched-route denials.\n */\nfunction makeDispatcher<TApp = unknown>(\n map: Map<string, AdminRouteHandler<TApp>>,\n catchAll: AdminRouteHandler<TApp> | null,\n opts: DispatcherOptions,\n): AdminRouteHandler<TApp> {\n return async (req, ctx) => {\n const first = ctx.segments[0] ?? ''\n const handler = map.get(first)\n if (handler) {\n return handler(req, ctx)\n }\n\n // Fall through to the prefix-level catch-all if one was declared\n // for this (prefix, method) via `matchAnyPath: true`. The catch-all\n // is itself wrapper-gated (its `guardedHandler` emits\n // `permission.denied` on deny), so the security contract is the\n // same as a matched route — only the dispatch-miss `kind:\n // 'dispatch-miss'` forensic enrichment is skipped, by design (see\n // `DefineAdminRouteSpec.matchAnyPath` JSDoc).\n if (catchAll) {\n return catchAll(req, ctx)\n }\n\n // Dispatch miss with no catch-all. Decide between honest 404 and\n // prefix-hiding 403. Per #371: the leak we're closing is \"non-admin\n // can probe admin URL structure by status code.\" If the caller has\n // ANY visibility into this prefix, dispatcher-miss 404 reveals\n // nothing new — they already see the prefix's other routes.\n const hasAnyAccess = opts.prefixPermissions.some((p) =>\n ctx.checkPermission(p.resource, p.action),\n )\n if (hasAnyAccess) {\n return new Response(JSON.stringify({ error: 'Not found' }), {\n status: 404,\n headers: { 'content-type': 'application/json' },\n })\n }\n\n // Caller has zero access at this prefix. 403 + audit so the response\n // is indistinguishable from \"you can't access a known route here\"\n // and the probe leaves a forensic trail. `prefixPermissions[0]` is\n // the canonical \"permission the caller would need to even see this\n // prefix exists\" — using a representative permission keeps the\n // audit shape uniform with wrapper-emitted entries.\n //\n // Defensive: `prefixPermissions` is always non-empty when\n // `combineAdminRoutes` builds the dispatcher (at least one entry\n // per (prefix, method) combination). The defensive `?? null` lets\n // TypeScript narrow without throwing on a hypothetical empty case.\n const representative = opts.prefixPermissions[0] ?? null\n if (representative === null) {\n return new Response(JSON.stringify({ error: 'Not found' }), {\n status: 404,\n headers: { 'content-type': 'application/json' },\n })\n }\n\n const permissionString = `${representative.resource}:${representative.action}`\n // Audit metadata enrichments specific to dispatch-miss:\n // - `kind: 'dispatch-miss'` — discriminator so audit-search can\n // split this 403 population from wrapper-gated 403s without\n // having to join against the catalog. Wrapper emits don't set\n // `kind`; absence = wrapper-gated.\n // - `requiredAny: string[]` — the full prefix permission set the\n // caller lacks (vs. the single representative `permission`\n // field). Forensics gets the complete picture: \"caller has\n // none of [ticket:view, ticket:update, ticket:create]\" beats\n // \"caller doesn't have ticket:view\" when investigating a probe.\n emitPermissionDenied(ctx, {\n permission: permissionString,\n method: opts.method,\n prefix: opts.prefix,\n path: first,\n extraMetadata: {\n kind: 'dispatch-miss',\n requiredAny: opts.prefixPermissions.map((p) => `${p.resource}:${p.action}`),\n },\n })\n return permissionDeniedResponse(permissionString, ctx.user.role)\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@murumets-ee/admin-route",
3
+ "version": "0.37.0",
4
+ "license": "Elastic-2.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.mts",
9
+ "import": "./dist/index.mjs"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "devDependencies": {
16
+ "tsdown": "^0.22.2",
17
+ "typescript": "^5.7.2",
18
+ "vitest": "^2.1.8"
19
+ },
20
+ "typeCoverage": {
21
+ "atLeast": 100
22
+ },
23
+ "scripts": {
24
+ "build": "tsdown",
25
+ "dev": "tsdown --watch",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest"
28
+ }
29
+ }