@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.
- package/LICENSE +94 -0
- package/dist/index.d.mts +998 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +29 -0
package/dist/index.d.mts
ADDED
|
@@ -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
|