@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,998 @@
1
+ //#region src/admin-route.d.ts
2
+ /**
3
+ * Types for the admin API plugin route system.
4
+ *
5
+ * Defined in `@murumets-ee/admin-route` — a dependency-free leaf (F020,
6
+ * F024) — so that every package, including ones that structurally cannot
7
+ * depend on `@murumets-ee/core` (e.g. `@murumets-ee/logging`; `core`
8
+ * imports `logging`, so the reverse edge would close a cycle), can import
9
+ * these types without circular dependencies.
10
+ *
11
+ * `@murumets-ee/core` re-exports these from its own barrel, binding the
12
+ * `TApp` generic below to the real `ToolkitApp` (see `packages/core/src/
13
+ * admin-route.ts`) so existing consumers see the exact same shape they
14
+ * always have — this file itself must stay dependency-free and cannot
15
+ * name `ToolkitApp`.
16
+ */
17
+ /**
18
+ * The nominal gating brand carried by every legitimately-minted
19
+ * {@link AdminRoute} (`plan/admin-api-hardening/` PR 3, SD002).
20
+ *
21
+ * ## Why this exists
22
+ *
23
+ * An `AdminRoute` used to be a plain structural shape — `{ prefix,
24
+ * handlers }` — so ANY object literal satisfied it, including one whose
25
+ * handlers perform no permission check at all. That is the exact defect
26
+ * class this plan exists to close: PR 2 proved every route is *currently*
27
+ * gated; this brand makes an ungated one impossible to construct.
28
+ *
29
+ * `combineAdminRoutes` is the SOLE minter, and it accepts ONLY
30
+ * `defineAdminRoute` outputs — `AdminRouteEntry` carries its own brand
31
+ * (`ENTRY_GATED`, `define-admin-route.ts`) which the combiner requires at
32
+ * both the type level and, fail-closed, at runtime. Since
33
+ * `defineAdminRoute` requires a `permission`, "carries the brand"
34
+ * transitively means "went through a permission gate".
35
+ *
36
+ * That transitivity used to be an unchecked assumption, and it was FALSE:
37
+ * `AdminRouteEntry` was a purely structural, publicly exported shape, so
38
+ * a hand-written literal (with any `guardedHandler` at all, or none) fed
39
+ * to `combineAdminRoutes` produced a genuinely branded `AdminRoute` that
40
+ * passed {@link isGatedRoute} and served every request under its prefix
41
+ * ungated. Finding H1 of PR 3 closed that by branding the entry too; see
42
+ * `ENTRY_GATED`'s doc comment for the full account.
43
+ *
44
+ * ## Threat model — be precise about what this does and does not stop
45
+ *
46
+ * These layers defend against **author error and `as` casts**: a route
47
+ * declared without a gate, by someone who did not realise one was needed.
48
+ * That is the failure mode this plan actually observed, four times over.
49
+ *
50
+ * They do NOT defend against hostile code already running in the server
51
+ * process. Symbols are not capabilities: anything holding a legitimately
52
+ * minted route can read the key off it via `Object.getOwnPropertySymbols`
53
+ * and stamp a forgery. That is not a weakness worth closing — a malicious
54
+ * plugin has arbitrary in-process execution and does not need a forged
55
+ * route to do harm — but do not mistake {@link isGatedRoute} for a
56
+ * sandbox, and do not let it become the justification for loading
57
+ * untrusted plugin code.
58
+ *
59
+ * ## Why a real `Symbol()`, not a `declare const` phantom
60
+ *
61
+ * `@murumets-ee/blocks` brands `BlockRenderer` with a `declare const`
62
+ * (`BLOCK_RENDER_BRAND`, `packages/blocks/src/core/define-theme.ts`) — a
63
+ * pure type-level phantom that is erased at compile time. That is not
64
+ * enough here. Types are erased and a plain-JS plugin never runs `tsc` at
65
+ * all, so the brand alone is *author ergonomics*; the runtime guarantee is
66
+ * {@link isGatedRoute}, checked fail-closed where routes are collected
67
+ * (`getRouteMap` in `@murumets-ee/admin-ui`). A phantom brand would leave
68
+ * that check nothing to read. SD002 locked "defense in depth, not
69
+ * either/or" for exactly this reason (OWASP: no single control should be
70
+ * the sole enforcement).
71
+ *
72
+ * ## Why `unique symbol`, and why it is not exported from the barrel
73
+ *
74
+ * `GATED` is exported from THIS MODULE (so `define-admin-route.ts`'s
75
+ * minter can name the key with no cast) but deliberately NOT re-exported
76
+ * from `src/index.ts`, and the package's `exports` map has no deep paths.
77
+ * No code outside this package can name the key, so an ungated object
78
+ * literal is a compile error at the boundary. A `unique symbol` is what
79
+ * makes that airtight: a same-named `Symbol('lumi.admin-route.gated')`
80
+ * declared elsewhere is a DIFFERENT `unique symbol` type and does not
81
+ * satisfy the property — where a string-literal brand would be trivially
82
+ * forgeable.
83
+ *
84
+ * ## Why it is set as a plain, enumerable own property
85
+ *
86
+ * The minter assigns it in the object literal rather than via
87
+ * `Object.defineProperty(..., { enumerable: false })`. It MUST survive
88
+ * object spread (`{ ...route }`), or a route the compiler blessed would
89
+ * lose its brand at runtime and {@link isGatedRoute} would reject it —
90
+ * layer 3 refusing something layer 2 approved. Symbol keys are already
91
+ * excluded from `Object.keys`, `for…in` and `JSON.stringify`, so leaving
92
+ * it enumerable costs nothing in serialization noise.
93
+ *
94
+ * ## Module-instance caveat
95
+ *
96
+ * Symbol identity is per module instance. Every package that mints routes
97
+ * and the one package that checks them (`admin-ui`) all resolve the same
98
+ * `@murumets-ee/admin-route` (single workspace symlink; a single hoisted
99
+ * version once published, since `@murumets-ee/*` is one fixed changeset
100
+ * group), so there is exactly one `GATED`. `Symbol.for()` would survive
101
+ * module duplication but lives in the cross-realm global registry, where
102
+ * any code could re-derive the key — trading the forgery guarantee for a
103
+ * hazard that does not exist here. If duplication ever did occur the
104
+ * failure is loud and fail-safe: every route logs an error and 404s,
105
+ * rather than silently registering ungated.
106
+ */
107
+ declare const GATED: unique symbol;
108
+ /** Authenticated user returned by the handler's authenticate callback. */
109
+ interface AuthUser {
110
+ id: string;
111
+ role?: string;
112
+ name?: string;
113
+ email?: string;
114
+ }
115
+ /** Fire-and-forget audit log function passed to plugin route handlers. */
116
+ type AuditLogFn = (entry: {
117
+ action: string;
118
+ entityType?: string;
119
+ entityId?: string;
120
+ userId?: string;
121
+ userName?: string;
122
+ changes?: Record<string, unknown>;
123
+ metadata?: Record<string, unknown>;
124
+ }) => void;
125
+ /**
126
+ * Synchronous permission checker — `(role, resource, action) => boolean`.
127
+ *
128
+ * Built by `buildPermissionChecker()` from saved role definitions.
129
+ * - `admin` role: always returns `true` (hardcoded safety net)
130
+ * - All other roles: exact match from settings (deny-by-default)
131
+ */
132
+ type PermissionChecker = (role: string, resource: string, action: string) => boolean;
133
+ /**
134
+ * Handler function for a plugin-provided admin API route.
135
+ *
136
+ * Handlers run inside `runWithContextAsync` — `getCurrentApp()`,
137
+ * `getCurrentLocale()`, `getCurrentDefaultLocale()` are available.
138
+ * `ctx.app` carries the SAME running app instance as an explicit,
139
+ * non-optional field rather than requiring the handler to reach for
140
+ * `getCurrentApp()` (which returns `ToolkitApp | undefined` on the
141
+ * `@murumets-ee/core` side). This is dependency injection, not a
142
+ * duplicate mechanism: a "leaf" package (per CLAUDE.md's package-
143
+ * boundary rule — `logging`, `settings`, etc.) that needs the running
144
+ * app inside a route handler can read `ctx.app` without importing
145
+ * `@murumets-ee/core` at all. Some of those packages genuinely CANNOT
146
+ * import core — `core` itself imports `logging` (`app.ts`), so
147
+ * `logging` importing `core` back would close a real cycle.
148
+ * `getCurrentApp()` remains available and unchanged for callers
149
+ * already inside core's dependency cone.
150
+ *
151
+ * `TApp` is generic (defaulting to `unknown`) because this leaf package
152
+ * cannot name `ToolkitApp` without depending on `@murumets-ee/core` —
153
+ * see the module-level doc comment (F024). `@murumets-ee/core` binds
154
+ * `TApp` to the real `ToolkitApp` in its own re-exported `AdminRouteHandler`
155
+ * alias, so every existing consumer that imports from `@murumets-ee/core`
156
+ * sees `ctx.app: ToolkitApp` exactly as before.
157
+ *
158
+ * @param req - The incoming request
159
+ * @param ctx.segments - Path segments after the route prefix has been consumed.
160
+ * e.g. for `/api/admin/media/abc-123`, the media route handler gets `['abc-123']`.
161
+ * @param ctx.user - The authenticated user
162
+ * @param ctx.locale - Content locale from the request (query param)
163
+ * @param ctx.defaultLocale - Default locale from handler config
164
+ * @param ctx.audit - Fire-and-forget audit logger (undefined if no auditLogger configured)
165
+ * @param ctx.checkPermission - Bound permission checker for the current user's role.
166
+ * Used by multi-entity routes (e.g. taxonomy) for per-entity permission checks.
167
+ * @param ctx.app - The running app instance — the SAME reference the
168
+ * dispatcher resolved via `getApp()` for this request, not a fresh construction.
169
+ * Required (not optional): there is exactly one production construction site
170
+ * and it always has the value, so every handler can rely on it being present.
171
+ */
172
+ type AdminRouteHandler<TApp = unknown> = (req: Request, ctx: {
173
+ segments: string[];
174
+ user: AuthUser;
175
+ locale?: string;
176
+ defaultLocale?: string;
177
+ audit?: AuditLogFn;
178
+ checkPermission: (resource: string, action: string) => boolean;
179
+ app: TApp;
180
+ }) => Promise<Response>;
181
+ /**
182
+ * A plugin-provided route group for the admin API handler.
183
+ *
184
+ * This is the lower-level shape `createAdminApiHandler` consumes. It is
185
+ * **not constructible by hand**: the {@link GATED} brand below can only be
186
+ * named inside this package, so `combineAdminRoutes` is the only way to
187
+ * produce one — and the combiner in turn accepts only `defineAdminRoute`
188
+ * outputs. Two independent things enforce that on the combiner's input
189
+ * (finding H1 and its residual):
190
+ *
191
+ * - `AdminRouteEntry` carries its own non-exported brand (`ENTRY_GATED`),
192
+ * required at compile time and re-checked at runtime, so a hand-built
193
+ * entry literal is refused.
194
+ * - The combiner additionally requires the entry's `guardedHandler` to
195
+ * be a function `defineAdminRoute` actually minted (tracked in a
196
+ * module-private `WeakSet`). The brand alone was NOT enough: it is an
197
+ * enumerable property, so `{ ...realEntry, guardedHandler: mine }`
198
+ * carried it faithfully while discarding the gate, with zero casts.
199
+ *
200
+ * `defineAdminRoute` requires a `permission` and builds the gate itself,
201
+ * so neither end of that chain admits an ungated handler. There is no
202
+ * "declare a prefix and gate it yourself" shape any more; the legacy
203
+ * top-level `resource` / `actions` gate was retired in
204
+ * `plan/admin-api-hardening/` PR 3 (F006).
205
+ *
206
+ * The guarantee is scoped to author error and `as` casts. Code already
207
+ * executing in the process can read either brand off a legitimately
208
+ * minted value — it just cannot get a forged `guardedHandler` past
209
+ * `combineAdminRoutes`, because function identity is not copyable.
210
+ *
211
+ * The centralized handler dispatches to plugin routes by matching the
212
+ * first path segment against the prefix.
213
+ *
214
+ * @example
215
+ * ```typescript
216
+ * // The ONLY shape. Each entry registers its permission in the catalog
217
+ * // and is wrapper-gated automatically (`guardedHandler`), so a route
218
+ * // that reaches dispatch has provably passed a permission check.
219
+ * import { type AdminRoute, combineAdminRoutes, defineAdminRoute } from '@murumets-ee/core'
220
+ *
221
+ * export function pluginRoutes(): AdminRoute[] {
222
+ * return combineAdminRoutes([
223
+ * defineAdminRoute({
224
+ * prefix: 'plugin', path: '', method: 'GET',
225
+ * permission: 'plugin:view',
226
+ * defaultRoles: ['admin', 'editor', 'agent', 'viewer'],
227
+ * handler: async (req, ctx) => { ... },
228
+ * }),
229
+ * defineAdminRoute({
230
+ * prefix: 'plugin', path: '', method: 'POST',
231
+ * permission: 'plugin:create',
232
+ * defaultRoles: ['admin'],
233
+ * handler: async (req, ctx) => { ... },
234
+ * }),
235
+ * ])
236
+ * }
237
+ *
238
+ * // ✗ Does NOT compile — TS2741, property '[GATED]' is missing. An
239
+ * // ungated admin route is unwritable, not merely discouraged.
240
+ * const rogue: AdminRoute = { prefix: 'plugin', handlers: { GET: h } }
241
+ * ```
242
+ */
243
+ interface AdminRoute<TApp = unknown> {
244
+ /**
245
+ * Nominal brand — present ONLY on routes minted by
246
+ * `combineAdminRoutes`. See {@link GATED} for the full rationale; the
247
+ * short version is that this property is what makes an ungated admin
248
+ * route a compile error rather than a code-review question.
249
+ */
250
+ readonly [GATED]: true;
251
+ /** URL prefix, e.g. 'media' matches `/api/admin/media/*` */
252
+ prefix: string;
253
+ /** HTTP method handlers */
254
+ handlers: {
255
+ GET?: AdminRouteHandler<TApp>;
256
+ POST?: AdminRouteHandler<TApp>;
257
+ PATCH?: AdminRouteHandler<TApp>;
258
+ DELETE?: AdminRouteHandler<TApp>;
259
+ };
260
+ }
261
+ /**
262
+ * Layer 3 of three — the RUNTIME half of the gating guarantee.
263
+ *
264
+ * Reports whether `value` carries the {@link GATED} brand, i.e. whether it
265
+ * was actually produced by `combineAdminRoutes` rather than merely
266
+ * *typed* as an {@link AdminRoute}. Safe to export: it reads the brand, it
267
+ * cannot mint it.
268
+ *
269
+ * The type layer (layer 2) is erased at runtime and never runs at all for
270
+ * a plain-JavaScript plugin or for TypeScript that reached the shape via
271
+ * `as`. Route collection therefore re-checks structurally and refuses
272
+ * anything unbranded — see `getRouteMap` in `@murumets-ee/admin-ui`.
273
+ *
274
+ * The predicate is deliberately `TApp`-agnostic (`AdminRoute<unknown>`):
275
+ * the brand says nothing about which app shape the handlers expect.
276
+ *
277
+ * **Narrowing caveat.** That genericity is not free. `AdminRoute<unknown>`
278
+ * IS assignable to `AdminRoute<SomeApp>` — the handlers are functions
279
+ * taking `ctx.app`, so `TApp` is contravariant and the `unknown`
280
+ * instantiation is the more permissive one. TypeScript therefore treats
281
+ * the predicate type as the narrower candidate and a caller that already
282
+ * held an `AdminRoute<SomeApp>` comes out of the guard holding
283
+ * `AdminRoute<unknown>`, losing the app instantiation. Callers that need
284
+ * to keep it should use a `boolean`-returning assertion instead of
285
+ * narrowing on this predicate — which is what `@murumets-ee/admin-ui`'s
286
+ * `assertGated` does, so nothing in-tree is affected today.
287
+ */
288
+ declare function isGatedRoute(value: unknown): value is AdminRoute;
289
+ //#endregion
290
+ //#region src/permissions/resolved.d.ts
291
+ /**
292
+ * The public permission-string type.
293
+ *
294
+ * ## History — issue #357 typed-union approach (reversed 2026-07-02)
295
+ *
296
+ * PRs 1–4 of #357 shipped a compile-time typed union: every app augmented
297
+ * `PermissionStringRegistry` with `ResolvedPermissions<typeof config>`, materializing a
298
+ * flat `` `${resource}:${action}` `` union of *every* permission so `tsc` caught typos
299
+ * at `permission:` callsites (plus autocomplete).
300
+ *
301
+ * Measured at real-app scale, that augmentation forced the entire `typeof config` type
302
+ * (~27k type-instantiations across ~9 plugin factories) into the resolution of
303
+ * `PermissionString` — which is referenced at every `permission:` / `requires:` callsite.
304
+ * The resulting union sat right at TypeScript's union-complexity ceiling. Under a full
305
+ * `next build` (the extra `.next/types` program load) it tipped over **non-
306
+ * deterministically**: the union silently widened to the loose fallback, typo-detection
307
+ * degraded, and the `typed-permissions-demo` canary's `@ts-expect-error` guards became
308
+ * "unused" → `scaffold-e2e` failed on PRs while local `tsc` passed. The approach was
309
+ * fundamentally too fragile at scale (it degraded by *build environment*, not by code).
310
+ *
311
+ * The typo-detection GOAL is retained by a cheaper, robust mechanism:
312
+ * {@link import('@murumets-ee/auth').assertPermissionsResolvable} runs at boot, validates
313
+ * every declared route / page / sidebar permission against the runtime permission catalog,
314
+ * and throws loudly on an unknown `resource:action` — "fail loud at boot", O(n), no type
315
+ * ceiling, and it also validates the dynamic `` `${entity.name}:view` `` strings the typed
316
+ * union never could.
317
+ *
318
+ * So `PermissionString` is simply the shape `` `${string}:${string}` ``: callsites still
319
+ * type-accept a `resource:action` string; the boot check enforces that it actually exists.
320
+ */
321
+ type PermissionString = `${string}:${string}`;
322
+ //#endregion
323
+ //#region src/define-admin-route.d.ts
324
+ /** HTTP methods the admin API handler dispatches. */
325
+ type AdminRouteMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
326
+ /**
327
+ * Template-literal type that REJECTS multi-segment paths at compile
328
+ * time. A path containing `/` resolves to `never`, so the assignment
329
+ * fails with a TS error pointing at the literal that contains the
330
+ * slash.
331
+ *
332
+ * PR 5 of `PLAN-DECLARATIVE-PLUGINS.md` — addresses foot-gun F3
333
+ * (`defineAdminRoute({ path: 'accounts/:id/poll-now' })` throwing at
334
+ * registration with a runtime error). The runtime check survives as
335
+ * defense-in-depth (a malformed dist that slipped past TS, or a
336
+ * dynamic-string callsite with `as` cast), but typical plugin
337
+ * authors get the error on save, in their editor, before they even
338
+ * try to boot.
339
+ *
340
+ * @example
341
+ * ```ts
342
+ * // ✓ Compiles
343
+ * defineAdminRoute({ path: 'reply', ... })
344
+ *
345
+ * // ✗ TS error — 'reply/:ticketId' is not assignable to 'never'
346
+ * defineAdminRoute({ path: 'reply/:ticketId', ... })
347
+ * ```
348
+ *
349
+ * For URL shapes where `segments[0]` is itself a runtime value
350
+ * (e.g. `/media/<uuid>`), use `{ path: '', matchAnyPath: true }` —
351
+ * `''` (empty) is not multi-segment, so it satisfies the constraint.
352
+ *
353
+ * Same template-literal technique as `PermissionString` (PR #357).
354
+ */
355
+ type SegmentPath<P extends string> = P extends `${string}/${string}` ? never : P;
356
+ /**
357
+ * Spec passed to {@link defineAdminRoute}. `permission` is REQUIRED — a route
358
+ * without an explicit permission can't compile. There is no "publicly callable
359
+ * admin route" — see CLAUDE.md security section.
360
+ *
361
+ * Generic over `TApp` (the `ctx.app` type — see the `TApp` threading note
362
+ * above) and `P` (the literal `path` string, constrained by
363
+ * {@link SegmentPath}). Both default so a bare `DefineAdminRouteSpec`
364
+ * type reference (docs, non-instantiating usage) still resolves to the
365
+ * pre-extraction shape (`ctx.app: unknown`, `path: string`).
366
+ */
367
+ interface DefineAdminRouteSpec<TApp = unknown, P extends string = string> {
368
+ /**
369
+ * URL prefix segment, e.g. `'ticketing'` for `/api/admin/ticketing/*`.
370
+ * Routes sharing a prefix MUST be passed together to `combineAdminRoutes`
371
+ * — the resulting `AdminRoute` is keyed on this prefix.
372
+ */
373
+ prefix: string;
374
+ /**
375
+ * Sub-path within the prefix, e.g. `'reply'` for
376
+ * `/api/admin/ticketing/reply`. Pass `''` for the root path
377
+ * (`/api/admin/<prefix>`).
378
+ *
379
+ * **Single segment only.** `path` must not contain `/`. Dynamic
380
+ * sub-segments (`'reply/:ticketId'`-style paths) are NOT supported by
381
+ * the combiner — it dispatches strictly on `ctx.segments[0]`. Routes
382
+ * accepting dynamic IDs read them from `ctx.segments[1..]` inside the
383
+ * handler; the registered `path` should still be the static prefix
384
+ * (e.g. `'reply'`). The factory throws at registration time on input
385
+ * containing `/` — loud-at-boot is the design.
386
+ *
387
+ * For URL shapes where `segments[0]` itself is a runtime value
388
+ * (e.g. `/media/<uuid>`), set {@link matchAnyPath} = `true` with
389
+ * `path: ''` — the combiner then dispatches every unmatched sub-path
390
+ * through this entry's handler instead of returning dispatch-miss.
391
+ */
392
+ path: SegmentPath<P>;
393
+ /** HTTP method this route serves. */
394
+ method: AdminRouteMethod;
395
+ /**
396
+ * Permission required, in `'<resource>:<action>'` form. The api-handler
397
+ * verifies the caller's role has this grant before invoking `handler`;
398
+ * a missing grant returns 403 with no handler invocation. The string is
399
+ * also added to the catalog so role-default seeding and the audit UI
400
+ * pick it up automatically.
401
+ *
402
+ * Resolves to the literal union from
403
+ * `@murumets-ee/core`'s {@link PermissionStringRegistry} when the
404
+ * consuming app augments it (see `PermissionString` docs); otherwise
405
+ * falls back to the loose `${string}:${string}` template literal so
406
+ * dynamic per-resource callsites in toolkit packages keep compiling.
407
+ */
408
+ permission: PermissionString;
409
+ /**
410
+ * Built-in roles that should be granted this permission on first
411
+ * deploy / on `upsertBuiltInRoles`. Defaults to `[]` (admin always
412
+ * passes via the hardcoded safety net in `buildPermissionChecker`).
413
+ */
414
+ defaultRoles?: readonly string[];
415
+ /**
416
+ * Optional description for the Permission Matrix UI (PR-D) and audit
417
+ * logs. Plain-text, no markdown.
418
+ */
419
+ description?: string;
420
+ /** The actual handler — runs only after the permission check passes. */
421
+ handler: AdminRouteHandler<TApp>;
422
+ /**
423
+ * Opt-in catch-all flag for plugins whose top-level URL surface embeds a
424
+ * runtime-value segment directly after the prefix (e.g. `/media/<uuid>`,
425
+ * `/media/<uuid>/usage`). When `true`, the combiner's dispatcher
426
+ * delegates to this entry's `guardedHandler` for any `(prefix, method)`
427
+ * sub-path that no other registered `path` claims — instead of
428
+ * returning the default dispatch-miss 404/403.
429
+ *
430
+ * Constraints (enforced at registration / combine time):
431
+ *
432
+ * - `path` MUST be `''`. Static paths take precedence over the
433
+ * catch-all, so the path slot is reserved for the prefix-root.
434
+ * - At most ONE matchAnyPath entry per `(prefix, method)`. Two
435
+ * conflicting catch-alls would silently shadow each other.
436
+ * - The handler is still wrapper-gated on `permission`. The wrapper
437
+ * emits the same `permission.denied` audit on deny as every other
438
+ * `defineAdminRoute`. The TRADE-OFF: when the wrapper passes but
439
+ * the handler internally returns 404 for an unknown sub-path,
440
+ * there's no `kind: 'dispatch-miss'` forensic enrichment on the
441
+ * audit row (the wrapper-gated audit fires on permission deny,
442
+ * not on handler-level 404). For surfaces where the URL space is
443
+ * enumerable by callers with the permission anyway (media's
444
+ * `/media/<uuid>` — any holder of `media:view` can already list
445
+ * IDs via `GET /media`), the missing enrichment carries no
446
+ * forensic loss. Plugins whose sub-path namespace IS sensitive
447
+ * should use static paths instead.
448
+ *
449
+ * **Default `false`.** Use sparingly — static paths + the
450
+ * `kind: 'dispatch-miss'` audit are the preferred shape. The known
451
+ * use case is plugins whose URL surface predates the framework's
452
+ * static-path convention and where breaking clients to retrofit a
453
+ * sub-resource segment isn't worth the audit-enrichment win.
454
+ */
455
+ matchAnyPath?: boolean;
456
+ }
457
+ /**
458
+ * The nominal gating brand carried by every {@link AdminRouteEntry} that
459
+ * {@link defineAdminRoute} actually produced (`plan/admin-api-hardening/`
460
+ * PR 3, finding H1).
461
+ *
462
+ * ## Why a SECOND brand
463
+ *
464
+ * `GATED` (see `admin-route.ts`) brands the {@link AdminRoute} that
465
+ * `combineAdminRoutes` mints, on the reasoning "the combiner is fed only
466
+ * by `defineAdminRoute` outputs, so the brand transitively means 'went
467
+ * through a permission gate'". That premise was false: `AdminRouteEntry`
468
+ * was fully structural and publicly exported (from this package's barrel
469
+ * and re-exported by `@murumets-ee/core`), so a hand-written literal —
470
+ * every field a plain property, `guardedHandler` included — satisfied it.
471
+ * Feeding that literal to `combineAdminRoutes` produced a genuinely
472
+ * branded `AdminRoute` that passed `isGatedRoute`, registered in
473
+ * `getRouteMap`, and served every request under its prefix to any
474
+ * authenticated user with no permission check at all. The permission
475
+ * catalog never learned of it either, so `assertPermissionsResolvable`
476
+ * stayed silent.
477
+ *
478
+ * That is not a hypothetical shape. Returning a bare `AdminRouteEntry` is
479
+ * an established in-repo idiom (`priceCheckRouteEntry`,
480
+ * `replacementRouteEntries` in `@murumets-ee/commerce`) — both legitimately
481
+ * obtain theirs from `defineAdminRoute`, but an author copying the shape
482
+ * and building the literal by hand landed exactly here. Author error, the
483
+ * exact class PR 3 exists to close.
484
+ *
485
+ * Branding the ENTRY closes the gap at the same two layers as `GATED`:
486
+ * the required `readonly` member makes a hand-written literal a compile
487
+ * error, and `combineAdminRoutes` re-checks structurally at runtime for
488
+ * the plain-JS / `as`-cast paths where types are erased.
489
+ *
490
+ * ## Why it is not exported from the barrel
491
+ *
492
+ * Same rule as `GATED`: exported from THIS MODULE (so the minter and the
493
+ * combiner can name the key with no cast) but deliberately NOT re-exported
494
+ * from `src/index.ts`, and the package's `exports` map has no deep paths.
495
+ * A `unique symbol` makes that airtight — a same-named
496
+ * `Symbol('lumi.admin-route.entry')` declared elsewhere is a DIFFERENT
497
+ * `unique symbol` type and does not satisfy the property.
498
+ *
499
+ * ## Why a plain enumerable own property — and why that is not enough
500
+ *
501
+ * Identical rationale to `GATED`: it must survive `{ ...entry }` spread,
502
+ * or an entry the compiler blessed would be rejected at combine time.
503
+ * Symbol keys are already excluded from `Object.keys`, `for…in` and
504
+ * `JSON.stringify`, so enumerability costs nothing in serialization noise.
505
+ *
506
+ * But spread-survival is EXACTLY what makes the envelope the wrong anchor
507
+ * for provenance. The brand travels with a copy while the gate does not:
508
+ *
509
+ * ```ts
510
+ * const legit = defineAdminRoute({ …real permission, real gate… })
511
+ * combineAdminRoutes([{ ...legit, guardedHandler: async () => new Response('PWNED') }])
512
+ * ```
513
+ *
514
+ * That literal carries a genuine `ENTRY_GATED`, typechecks with zero casts
515
+ * and zero suppressions, and — until `MINTED_GUARDS` — combined into
516
+ * a fully-branded `AdminRoute` that served its whole prefix ungated. It is
517
+ * strictly EASIER than the hand-built literal this brand closed, which
518
+ * needed `as unknown as` plus eleven hand-written fields.
519
+ *
520
+ * So the brand is only ONE of the two layers. The runtime provenance
521
+ * anchor is `MINTED_GUARDS`, which is keyed on the identity of the
522
+ * `guardedHandler` function itself — the thing that actually holds the
523
+ * gate, and the thing a decorator swaps. The brand keeps its own job:
524
+ * making a hand-written literal a COMPILE error, which a `WeakSet` can
525
+ * never do.
526
+ *
527
+ * ## Threat model
528
+ *
529
+ * Unchanged from `GATED`: this stops author error and `as` casts, not
530
+ * hostile code already executing in the process, which can read the key
531
+ * off any legitimately minted entry via `Object.getOwnPropertySymbols`.
532
+ */
533
+ declare const ENTRY_GATED: unique symbol;
534
+ /**
535
+ * The compiled output of {@link defineAdminRoute}. The handler is wrapped
536
+ * in a permission gate (`guardedHandler`); the original is preserved as
537
+ * `handler` for tests + introspection.
538
+ *
539
+ * **Not constructible by hand, and not re-pointable after the fact.** Three
540
+ * things enforce that, and each covers a hole the others do not:
541
+ *
542
+ * 1. The {@link ENTRY_GATED} brand can only be named inside this package,
543
+ * so a hand-written literal is a COMPILE error.
544
+ * 2. Every field is `readonly`, so `entry.guardedHandler = mine` is a
545
+ * COMPILE error too — the plain-mutation form of the same attack.
546
+ * 3. `MINTED_GUARDS` records the `guardedHandler` function identity
547
+ * at mint time, and {@link combineAdminRoutes} requires it at RUNTIME.
548
+ * This is the only layer that catches `{ ...entry, guardedHandler:
549
+ * mine }` — a fresh literal, so `readonly` never applies to it, and a
550
+ * faithful spread, so the brand comes along for the ride.
551
+ *
552
+ * `defineAdminRoute` — which requires a `permission`, registers it in the
553
+ * catalog, and builds `guardedHandler` itself — is therefore the only way
554
+ * to produce a value that survives all three. Declare the entry's TYPE
555
+ * (`function xRouteEntries(): AdminRouteEntry[]`) freely; just build the
556
+ * values with the factory.
557
+ *
558
+ * Generic over `TApp` (see the `TApp` threading note above) — the
559
+ * registered `path` here is the plain compiled string, no longer
560
+ * constrained by {@link SegmentPath} (that constraint applies only at
561
+ * {@link defineAdminRoute}'s call site).
562
+ */
563
+ interface AdminRouteEntry<TApp = unknown> {
564
+ /**
565
+ * Nominal brand — present ONLY on entries minted by
566
+ * {@link defineAdminRoute}. See {@link ENTRY_GATED} for the full
567
+ * rationale; the short version is that this property is what stops a
568
+ * hand-built entry literal from laundering an ungated handler through
569
+ * `combineAdminRoutes` and out the other side as a branded
570
+ * {@link AdminRoute}. It does NOT, on its own, stop a *copy* of a real
571
+ * entry with a substituted gate — that is `MINTED_GUARDS`'s job.
572
+ */
573
+ readonly [ENTRY_GATED]: true;
574
+ readonly prefix: string;
575
+ readonly path: string;
576
+ readonly method: AdminRouteMethod;
577
+ readonly permission: string;
578
+ /** Parsed left side of `permission`. */
579
+ readonly resource: string;
580
+ /** Parsed right side of `permission`. */
581
+ readonly action: string;
582
+ readonly defaultRoles: readonly string[];
583
+ readonly description: string | undefined;
584
+ /** Original user handler (unwrapped). */
585
+ readonly handler: AdminRouteHandler<TApp>;
586
+ /**
587
+ * Permission-gated handler. Returns 403 if `ctx.checkPermission(resource,
588
+ * action)` is false; otherwise delegates to {@link handler}.
589
+ *
590
+ * On denial, emits a `permission.denied` audit entry via `ctx.audit`
591
+ * (when available) BEFORE the 403 response — no per-route audit wiring
592
+ * needed. The entry's metadata carries the permission string, caller
593
+ * role, HTTP method, route coordinates, and request segments.
594
+ *
595
+ * **This function's IDENTITY is the provenance anchor.** It is recorded
596
+ * in `MINTED_GUARDS` at mint time and re-checked by
597
+ * {@link combineAdminRoutes}. Replacing it — by assignment (blocked by
598
+ * `readonly`) or by spreading into a new literal (blocked at runtime) —
599
+ * is refused, INCLUDING by a decorator that faithfully calls through.
600
+ * To add behaviour around a route, wrap the `handler` you pass into
601
+ * {@link defineAdminRoute}.
602
+ */
603
+ readonly guardedHandler: AdminRouteHandler<TApp>;
604
+ /**
605
+ * Whether this entry catches every unmatched sub-path under
606
+ * `(prefix, method)`. Mirrors {@link DefineAdminRouteSpec.matchAnyPath}
607
+ * — captured on the compiled entry so `combineAdminRoutes` can wire
608
+ * the dispatcher's catch-all slot.
609
+ */
610
+ readonly matchAnyPath: boolean;
611
+ }
612
+ /**
613
+ * One entry per `(resource, action)` pair contributed by `defineAdminRoute`,
614
+ * `defineFeature`, or `defineAdminPage` (forthcoming). Consumed by:
615
+ *
616
+ * - `upsertBuiltInRoles` to seed default grants
617
+ * - Permission Matrix UI (PR-D)
618
+ * - `buildResourceCatalog` migration (PR-F)
619
+ */
620
+ interface PermissionCatalogEntry {
621
+ resource: string;
622
+ action: string;
623
+ /** `'<resource>:<action>'` — the full permission string. */
624
+ permission: string;
625
+ /** Roles that get this grant on a fresh upsert. */
626
+ defaultRoles: readonly string[];
627
+ /** Free-text description; surfaced in the audit UI. */
628
+ description: string | undefined;
629
+ /**
630
+ * Where this entry came from. `'route'` = `defineAdminRoute`,
631
+ * `'page'` = `defineAdminPage`, `'feature'` = `defineFeature`. The
632
+ * Matrix UI groups by this when rendering.
633
+ */
634
+ source: 'route' | 'page' | 'feature';
635
+ }
636
+ /**
637
+ * Register a permission in the catalog. Idempotent — repeat calls for the
638
+ * same permission union the `defaultRoles`. Exported for factory code only
639
+ * (`defineAdminRoute` / `defineFeature` / forthcoming `defineAdminPage`)
640
+ * to share the same map; plugin code should call a factory instead — the
641
+ * factories validate input shape (no `:` in resource/action, non-empty
642
+ * fields, etc.) that this raw primitive does not.
643
+ *
644
+ * **Returns the resulting catalog entry.** On a fresh registration this is
645
+ * the input `entry`; on a repeat registration this is the post-merge entry
646
+ * (existing first-wins `source` + `description`, unioned `defaultRoles`).
647
+ * Callers that need to expose the authoritative catalog state should use
648
+ * the return value rather than the input draft — see `defineFeature`.
649
+ */
650
+ declare function registerPermission(entry: PermissionCatalogEntry): PermissionCatalogEntry;
651
+ /**
652
+ * Snapshot of the current catalog as a `Map<permission, entry>`.
653
+ *
654
+ * Returns a NEW map each call — callers can mutate the result without
655
+ * affecting the registry. Read-only access to the live map is intentional
656
+ * — the registry's invariants (e.g. unioned defaults) belong to
657
+ * `registerPermission`, not to call sites.
658
+ */
659
+ declare function getPermissionCatalog(): Map<string, PermissionCatalogEntry>;
660
+ /**
661
+ * Test-only — clear the catalog between test suites that exercise factory
662
+ * registration. NEVER call in production code; the catalog is global state
663
+ * by design.
664
+ */
665
+ declare function _resetPermissionCatalog(): void;
666
+ /**
667
+ * Remove every catalog entry with `source: 'feature'`. Used by
668
+ * `resolveShell` to rebuild the feature subset deterministically on every
669
+ * call — features come exclusively from `Plugin.shared.features` in the
670
+ * input plugin list, so the catalog should reflect the CURRENT input,
671
+ * not the accumulated history.
672
+ *
673
+ * **Lifecycle contract:** `resolveShell` owns `source: 'feature'`
674
+ * registrations. Direct `defineFeature` callers (tests, scripts) that
675
+ * also call `resolveShell` will have their direct registrations cleared
676
+ * when `resolveShell` next runs — same as `defineAdminRoute` entries
677
+ * registered at module load aren't owned by `resolveShell` but features
678
+ * registered IN this call are.
679
+ *
680
+ * Test-only direct callers that need their features to survive a
681
+ * subsequent `resolveShell` either (a) re-call `defineFeature` after
682
+ * `resolveShell`, or (b) declare them via a fixture plugin in the
683
+ * `resolveShell` input.
684
+ */
685
+ declare function clearFeaturePermissions(): void;
686
+ /**
687
+ * **The canonical primitive** for emitting audit entries from a route handler.
688
+ *
689
+ * `ctx.audit?.(...)` directly is a footgun: a synchronous throw inside the
690
+ * audit adapter (bad serialization, malformed metadata, broken logger) would
691
+ * turn the intended response into a 500. This wrapper swallows sync throws —
692
+ * the response always lands.
693
+ *
694
+ * **Every audit emission from a route handler should go through this primitive
695
+ * instead of calling `ctx.audit?.(...)` directly.** That removes the
696
+ * "did the developer remember to add try/catch?" question from every call
697
+ * site — the safe primitive IS the API.
698
+ *
699
+ * Sync-throw isolation only. Async-rejection handling for the `void`-typed
700
+ * `AuditLogFn` happens centrally in `@murumets-ee/admin-ui`'s
701
+ * `buildAuditLogFn` (issue #373) — the adapter that fronts the actual audit
702
+ * logger is responsible for catching `Promise<void>` rejection.
703
+ *
704
+ * @example
705
+ * ```ts
706
+ * import { safeAudit } from '@murumets-ee/core'
707
+ *
708
+ * if (somethingFailed) {
709
+ * safeAudit(ctx, {
710
+ * action: 'commerce.import.run.rejected',
711
+ * entityType: 'import_run',
712
+ * userId: ctx.user.id,
713
+ * metadata: { reason: 'size mismatch', storageKey },
714
+ * })
715
+ * return errorJson('...', 400)
716
+ * }
717
+ * ```
718
+ */
719
+ declare function safeAudit(ctx: Parameters<AdminRouteHandler>[1], entry: Parameters<NonNullable<Parameters<AdminRouteHandler>[1]['audit']>>[0]): void;
720
+ /**
721
+ * Spec for {@link emitPermissionDenied}. Mirrors the subset of
722
+ * {@link DefineAdminRouteSpec} that's relevant to audit metadata. Inline-gated
723
+ * routes supply this manually; the wrapper builds it from its own spec.
724
+ */
725
+ interface PermissionDenialContext {
726
+ /** The permission that was denied, in `'<resource>:<action>'` form. */
727
+ permission: string;
728
+ /** HTTP method of the request. */
729
+ method: AdminRouteMethod;
730
+ /** URL prefix segment (the `prefix` field of the route's `defineAdminRoute` spec). */
731
+ prefix: string;
732
+ /** Path within the prefix (the `path` field; `''` for prefix-root). */
733
+ path: string;
734
+ /**
735
+ * Extra audit metadata merged into the persisted entry's `metadata` block.
736
+ *
737
+ * Use sparingly — most permission-denial events have everything they need
738
+ * from the standard fields. The two known consumers as of writing:
739
+ *
740
+ * - `kind: 'dispatch-miss'` from `makeDispatcher` so audit-search can
741
+ * distinguish wrapper-gated 403s (caller hit a registered route they
742
+ * can't access) from dispatcher-miss 403s (caller probed for an
743
+ * unknown sub-path under a prefix they have zero visibility into).
744
+ * Wrapper-gated emits don't set `kind`; absence = wrapper-gated.
745
+ * - `requiredAny: string[]` from `makeDispatcher` listing the full
746
+ * prefix permission set the caller lacks (vs. the single
747
+ * representative `permission` field). Lets forensics see whether
748
+ * the caller was missing one specific grant or all of them.
749
+ *
750
+ * Caller-supplied keys override standard keys on collision. Out-of-band
751
+ * `userId`/`userName` etc. are not allowed via this path (they live on
752
+ * the top-level audit entry, not in `metadata`).
753
+ */
754
+ extraMetadata?: Record<string, unknown>;
755
+ }
756
+ /**
757
+ * Emit the standard `permission.denied` audit entry for a denied request.
758
+ *
759
+ * Sync-throw isolated: a failing audit adapter MUST NOT turn the intended 403
760
+ * into a 500. Async-rejection handling for the `void`-typed `AuditLogFn` lives
761
+ * separately in `@murumets-ee/admin-ui`'s `buildAuditLogFn` (#373).
762
+ *
763
+ * The audit shape is filterable by `action = 'permission.denied'`, with
764
+ * structured metadata for forensics (permission, role, HTTP method, route
765
+ * coordinates, request segments). `userName` and `role` are conditionally
766
+ * spread so undefined values don't pollute the persisted metadata.
767
+ *
768
+ * `entityType: 'permission'` (singular) is intentional — it describes the
769
+ * abstract grant being denied. Compare with `@murumets-ee/auth`'s permission-
770
+ * management routes which use `entityType: 'permissions'` (plural) for
771
+ * actions like `permissions.update` / `permissions.role.create`. The semantic
772
+ * split: `permission` = "denial event"; `permissions` = "role-management
773
+ * resource being CRUD'd". Audit-search filters targeting either category
774
+ * should query both when looking for any permission-related activity.
775
+ */
776
+ declare function emitPermissionDenied(ctx: Parameters<AdminRouteHandler>[1], spec: PermissionDenialContext): void;
777
+ /**
778
+ * The standard 403 response for permission denial. Body shape:
779
+ * `{ error, code: 'forbidden' }`. Frontend callers can branch on
780
+ * `body.code === 'forbidden'` uniformly across every admin denial.
781
+ *
782
+ * Pair with {@link emitPermissionDenied} at every inline permission check:
783
+ *
784
+ * ```ts
785
+ * if (!ctx.checkPermission(resource, action)) {
786
+ * emitPermissionDenied(ctx, { permission: `${resource}:${action}`, method, prefix, path })
787
+ * return permissionDeniedResponse(`${resource}:${action}`, ctx.user.role)
788
+ * }
789
+ * ```
790
+ */
791
+ declare function permissionDeniedResponse(permission: string, role: string | undefined): Response;
792
+ /**
793
+ * Declare an admin API route with its required permission.
794
+ *
795
+ * The returned {@link AdminRouteEntry} is consumed by
796
+ * {@link combineAdminRoutes}, which collapses many entries into the
797
+ * legacy `AdminRoute[]` shape that `createAdminApiHandler` already
798
+ * dispatches.
799
+ *
800
+ * **Side effect:** registers the permission in the process-local
801
+ * catalog. Calling the factory at module-load time (the normal usage,
802
+ * inside a `routes/*.ts` file) means the catalog is fully populated by
803
+ * the time the api-handler boots.
804
+ *
805
+ * **Auto-audit:** when the wrapper denies a request (the caller's role
806
+ * lacks `permission`), it emits a `permission.denied` entry via
807
+ * `ctx.audit` before returning 403. Metadata includes the permission
808
+ * string, caller role, HTTP method, route coordinates, and segments.
809
+ * Every denial leaves a trail without per-route boilerplate; the prior
810
+ * pattern relied on inline `auditRejection`-style helpers that were
811
+ * forgotten on most routes (search, parts-search, taxonomy, etc.),
812
+ * making UUID-enumeration probes / permission-bypass attempts invisible
813
+ * post-hoc. Filter the audit search on `action = 'permission.denied'`.
814
+ *
815
+ * @example
816
+ * ```ts
817
+ * import { defineAdminRoute } from '@murumets-ee/core'
818
+ *
819
+ * export const replyRoute = defineAdminRoute({
820
+ * prefix: 'ticketing',
821
+ * path: 'reply',
822
+ * method: 'POST',
823
+ * permission: 'ticket:update',
824
+ * defaultRoles: ['admin', 'agent'],
825
+ * description: 'Post an agent reply to a ticket conversation',
826
+ * handler: async (req, ctx) => { return new Response('ok') },
827
+ * })
828
+ * ```
829
+ */
830
+ declare function defineAdminRoute<TApp = unknown, const P extends string = string>(spec: DefineAdminRouteSpec<TApp, P>): AdminRouteEntry<TApp>;
831
+ /**
832
+ * Spec passed to {@link defineFeature}.
833
+ *
834
+ * Plugin-level NON-CRUD resources whose permission grants aren't tied to a
835
+ * single route or entity — e.g. `ticketing.bulk-edit` with actions
836
+ * `['view', 'execute']`, or `commerce.imports` with `['view', 'export',
837
+ * 'cancel']`. Use `defineAdminRoute` for routes and `defineAdminPage` for
838
+ * pages; `defineFeature` is the catalog hook for everything that's neither.
839
+ *
840
+ * The resource string is the same shape as a `defineAdminRoute` permission's
841
+ * left side. Actions are the right side — one catalog entry is emitted per
842
+ * `(resource, action)` pair, exactly as if `defineAdminRoute` had been
843
+ * called once per action with a no-op handler.
844
+ *
845
+ * @example
846
+ * ```ts
847
+ * defineFeature({
848
+ * resource: 'ticketing.bulk-edit',
849
+ * actions: ['view', 'execute'],
850
+ * defaultRoles: ['admin', 'agent'],
851
+ * description: 'Operate ticketing bulk-edit tools',
852
+ * })
853
+ * ```
854
+ */
855
+ interface DefineFeatureSpec {
856
+ /**
857
+ * Resource string — left side of the permission. Must be non-empty and
858
+ * not contain `:` (the action separator). Convention: dot-notation for
859
+ * plugin-prefixed names (`'ticketing.bulk-edit'`, `'commerce.imports'`).
860
+ */
861
+ resource: string;
862
+ /**
863
+ * Actions on this resource. Each becomes a catalog entry
864
+ * `<resource>:<action>`. Must be non-empty; each action must be a
865
+ * non-empty string without `:`. Duplicates within the same call are
866
+ * rejected (would be silently deduped by `registerPermission` but the
867
+ * call-site shape suggests programmer error).
868
+ */
869
+ actions: readonly string[];
870
+ /**
871
+ * Built-in roles that should be granted EVERY action on this resource
872
+ * on first deploy / on `upsertBuiltInRoles`. Defaults to `[]`. The same
873
+ * default-roles set applies to every action — if different actions
874
+ * need different default grants, register separate `defineFeature`
875
+ * calls (or use `defineAdminRoute` for the action that needs a
876
+ * different grant).
877
+ */
878
+ defaultRoles?: readonly string[];
879
+ /**
880
+ * Optional description for the Permission Matrix UI (PR-D) and audit
881
+ * logs. Plain-text, no markdown. Applied to every catalog entry the
882
+ * call produces; per-action descriptions need separate `defineFeature`
883
+ * calls (one per action).
884
+ */
885
+ description?: string;
886
+ }
887
+ /**
888
+ * Register a non-CRUD plugin resource in the permission catalog.
889
+ *
890
+ * **Plugin authors should NOT call this directly.** Contribute features
891
+ * declaratively via `Plugin.shared.features: DefineFeatureSpec[]` — the
892
+ * framework's merge step at boot iterates that array and calls this
893
+ * factory for each spec. The declarative path is the supported plugin-
894
+ * authoring API; direct invocation is reserved for tests / scripts /
895
+ * non-plugin call sites that need to populate the catalog explicitly.
896
+ *
897
+ * The returned `PermissionCatalogEntry[]` contains ONE entry per `(resource,
898
+ * action)` pair — the same shape every other catalog consumer
899
+ * (`upsertBuiltInRoles`, Permission Matrix UI, `buildResourceCatalog`)
900
+ * already understands. Each returned entry reflects the AUTHORITATIVE
901
+ * post-merge catalog state: on a fresh registration it equals the input
902
+ * spec's fields; on a repeat registration (something else already
903
+ * contributed the same `(resource, action)` pair) the entry carries the
904
+ * first registrant's `source` + `description` and the union of every
905
+ * registrant's `defaultRoles`. Callers consume the return value as a
906
+ * truthful snapshot, not the draft this call would have registered.
907
+ *
908
+ * Unlike {@link defineAdminRoute}, `defineFeature` does NOT produce a
909
+ * handler — its only contribution is the catalog registration. Use this
910
+ * for non-route, non-entity-action permissions that the toolkit's auth
911
+ * layer should know about: feature flags an admin can grant per-role,
912
+ * bulk operations that aren't a discrete HTTP endpoint, scheduled-job
913
+ * "run-now" capabilities, etc.
914
+ *
915
+ * **Why a wrapper exists when `registerPermission` already does the job:**
916
+ * the wrapper validates input shape (non-empty resource, non-empty
917
+ * actions, no `:` in either, no duplicate actions in the same call),
918
+ * splits a multi-action declaration into the right number of catalog
919
+ * entries, and sets `source: 'feature'` so the Matrix UI can group by
920
+ * declaration site. `registerPermission` is the lower-level primitive
921
+ * shared with `defineAdminRoute`; plugin code should reach for
922
+ * `defineFeature` instead.
923
+ *
924
+ * **Idempotency:** repeat calls for the same `(resource, action)` pair
925
+ * union their `defaultRoles` (same behavior as `defineAdminRoute`
926
+ * registering the same permission twice). Two plugins both declaring
927
+ * `'ticketing.bulk-edit:execute'` produce one catalog entry with the
928
+ * combined defaults.
929
+ *
930
+ * @example Plugin authoring (declarative — preferred):
931
+ * ```ts
932
+ * import type { Plugin } from '@murumets-ee/core'
933
+ *
934
+ * export function ticketingPlugin(): Plugin {
935
+ * return {
936
+ * name: '@app/ticketing',
937
+ * shared: {
938
+ * features: [
939
+ * {
940
+ * resource: 'ticketing.bulk-edit',
941
+ * actions: ['view', 'execute'],
942
+ * defaultRoles: ['admin', 'agent'],
943
+ * description: 'Operate ticketing bulk-edit tools',
944
+ * },
945
+ * ],
946
+ * },
947
+ * }
948
+ * }
949
+ * ```
950
+ *
951
+ * @example Tests / scripts (direct invocation):
952
+ * ```ts
953
+ * import { defineFeature } from '@murumets-ee/core'
954
+ *
955
+ * const entries = defineFeature({
956
+ * resource: 'ticketing.bulk-edit',
957
+ * actions: ['view', 'execute'],
958
+ * defaultRoles: ['admin', 'agent'],
959
+ * })
960
+ * // entries[0].source === 'feature'
961
+ * ```
962
+ */
963
+ declare function defineFeature(spec: DefineFeatureSpec): PermissionCatalogEntry[];
964
+ /**
965
+ * Collapse a list of `defineAdminRoute` outputs into the legacy
966
+ * `AdminRoute[]` shape that `createAdminApiHandler` dispatches.
967
+ *
968
+ * Routes are grouped by `prefix`. Each prefix's resulting `AdminRoute`
969
+ * gets per-method handlers that dispatch on the leading path segment
970
+ * matching the entry's `path` (or the root segment when `path === ''`).
971
+ *
972
+ * Sub-path dispatch is strictly single-level — `path` cannot contain
973
+ * `/` (rejected by `defineAdminRoute` at registration). Dynamic IDs are
974
+ * read by the handler from `ctx.segments[1..]`. This matches the
975
+ * existing convention every plugin already uses (see ticketing's
976
+ * `dispatch()` for the prior art).
977
+ *
978
+ * Conflict detection: two entries claiming the same `(prefix, method,
979
+ * path)` triple throw at combine time — better to fail at boot than to
980
+ * silently shadow a route.
981
+ *
982
+ * Provenance check, in two parts (finding H1 and its residual):
983
+ *
984
+ * 1. The entry must carry the {@link ENTRY_GATED} brand — it must LOOK
985
+ * like something {@link defineAdminRoute} produced.
986
+ * 2. Its `guardedHandler` must be one {@link defineAdminRoute} actually
987
+ * minted (`MINTED_GUARDS`). Part 1 alone is insufficient: a
988
+ * spread copy carries the brand while substituting the gate.
989
+ *
990
+ * Either failure throws. **A decorator that wraps `guardedHandler` fails
991
+ * part 2 and is refused** — intentionally, since a wrapper's continued
992
+ * gating cannot be verified from here. Wrap the `handler` passed into
993
+ * {@link defineAdminRoute} instead.
994
+ */
995
+ declare function combineAdminRoutes<TApp = unknown>(entries: readonly AdminRouteEntry<TApp>[]): AdminRoute<TApp>[];
996
+ //#endregion
997
+ export { type AdminRoute, type AdminRouteEntry, type AdminRouteHandler, type AdminRouteMethod, type AuditLogFn, type AuthUser, type DefineAdminRouteSpec, type DefineFeatureSpec, type PermissionCatalogEntry, type PermissionChecker, type PermissionDenialContext, type PermissionString, type SegmentPath, _resetPermissionCatalog, clearFeaturePermissions, combineAdminRoutes, defineAdminRoute, defineFeature, emitPermissionDenied, getPermissionCatalog, isGatedRoute, permissionDeniedResponse, registerPermission, safeAudit };
998
+ //# sourceMappingURL=index.d.mts.map