@web-ts-toolkit/access-router 0.47.1 → 0.49.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/README.md +529 -1
- package/advanced.d.mts +110 -13
- package/advanced.d.ts +110 -13
- package/advanced.js +422 -20
- package/advanced.mjs +424 -21
- package/index.d.mts +134 -45
- package/index.d.ts +134 -45
- package/index.js +8837 -4773
- package/index.mjs +8786 -4712
- package/llms.txt +138 -2
- package/package.json +3 -3
- package/{parsers-BidvJUu7.d.mts → parsers-DhUuvj9B.d.mts} +941 -55
- package/{parsers-BidvJUu7.d.ts → parsers-DhUuvj9B.d.ts} +941 -55
package/README.md
CHANGED
|
@@ -59,6 +59,7 @@ const userRouter = acl.createRouter('User', {
|
|
|
59
59
|
|
|
60
60
|
const fruitRouter = acl.createDataRouter('fruit', {
|
|
61
61
|
basePath: '/fruit',
|
|
62
|
+
idParam: 'id',
|
|
62
63
|
idField: 'id',
|
|
63
64
|
operationAccess: { list: true, read: true },
|
|
64
65
|
data: [{ id: 'apple', name: 'Apple', public: true }],
|
|
@@ -136,7 +137,348 @@ calling `res.json(result)` does not automatically set HTTP 403; map its code exp
|
|
|
136
137
|
Advanced mutation validators drive dispatch: nested `data` validators run first, then the whole-body (`default`)
|
|
137
138
|
validator sees that transformed envelope, and the service persists only the final parsed `data`, `select`,
|
|
138
139
|
`populate`, `tasks`, and allowed `options`. Missing body options fall back to `returning_all`/`include_permissions`
|
|
139
|
-
query params; only `includePermissions`/`populateAccess` (plus `returningAll` for update/upsert) are forwarded.
|
|
140
|
+
query params; only `includePermissions`/`includeFieldPermissions`/`populateAccess` (plus `returningAll` for update/upsert) are forwarded.
|
|
141
|
+
|
|
142
|
+
## Basic and advanced operation access
|
|
143
|
+
|
|
144
|
+
Configure generated endpoint guards in `operationAccess`. A base rule such as `read`
|
|
145
|
+
covers both variants unless an exact `basicRead` or `advancedRead` rule overrides it.
|
|
146
|
+
Variants authorize **route entry**; field grants remain in `permissionSchema` under
|
|
147
|
+
base names such as `read` and `list`.
|
|
148
|
+
|
|
149
|
+
<!-- doc-example: partial -->
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import type { ModelRouterOptions } from '@web-ts-toolkit/access-router';
|
|
153
|
+
|
|
154
|
+
// Both list and both read variants inherit their base guards.
|
|
155
|
+
const baseOnly = {
|
|
156
|
+
operationAccess: { list: true, read: true },
|
|
157
|
+
permissionSchema: { name: true },
|
|
158
|
+
} satisfies ModelRouterOptions;
|
|
159
|
+
|
|
160
|
+
// Among top-level routes, deny GET /users/:id only; both advanced read POSTs inherit read: true.
|
|
161
|
+
const advancedReadsOnly = {
|
|
162
|
+
basePath: '/users',
|
|
163
|
+
operationAccess: { list: true, read: true, basicRead: false },
|
|
164
|
+
permissionSchema: { name: true },
|
|
165
|
+
} satisfies ModelRouterOptions;
|
|
166
|
+
|
|
167
|
+
// Keep GET /users, deny POST /users/__query.
|
|
168
|
+
const basicListsOnly = {
|
|
169
|
+
basePath: '/users',
|
|
170
|
+
operationAccess: { list: true, read: true, advancedList: false },
|
|
171
|
+
permissionSchema: { name: true },
|
|
172
|
+
} satisfies ModelRouterOptions;
|
|
173
|
+
// Pass one of these objects to acl.createRouter(UserModel, options).
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`basicRead: false` does not affect list, new, count, distinct, or mutation routes;
|
|
177
|
+
their own guards still apply. Subdocument reads follow the field precedence below.
|
|
178
|
+
Denied operations return **HTTP 401 Unauthorized**
|
|
179
|
+
before validation/service dispatch. Every route stays registered in
|
|
180
|
+
`router.router.getEndpoints()` and OpenAPI. Express HEAD fallback uses the
|
|
181
|
+
corresponding GET's basic guard, so HEAD-by-id is denied with basic read.
|
|
182
|
+
|
|
183
|
+
### Complete operation matrix
|
|
184
|
+
|
|
185
|
+
Paths are relative to `basePath`, with model defaults `idParam: 'id'`,
|
|
186
|
+
`queryRouteSegment: '__query'`, and `mutationRouteSegment: '__mutation'`.
|
|
187
|
+
Changing those route-shape options changes paths, not guard names. For data routers,
|
|
188
|
+
set `idParam: 'id'` explicitly to use the illustrated identifier paths.
|
|
189
|
+
|
|
190
|
+
| Base guard | Basic endpoint → override | Advanced endpoint → override |
|
|
191
|
+
| ----------------- | ------------------------------------------------ | -------------------------------------------------------------- |
|
|
192
|
+
| `list` | `GET /` → `basicList` | `POST /__query` → `advancedList` |
|
|
193
|
+
| `read` | `GET /:id` → `basicRead` | `POST /__query/:id`, `POST /__query/__filter` → `advancedRead` |
|
|
194
|
+
| `create` | `POST /` → `basicCreate` | `POST /__mutation` → `advancedCreate` |
|
|
195
|
+
| `update` | `PATCH /:id` → `basicUpdate` | `PATCH /__mutation/:id` → `advancedUpdate` |
|
|
196
|
+
| `upsert` | `PUT /` → `basicUpsert` | `PUT /__mutation` → `advancedUpsert` |
|
|
197
|
+
| `count` | `GET /count` → `basicCount` | `POST /count` → `advancedCount` |
|
|
198
|
+
| `distinct` | `GET /distinct/:field` → `basicDistinct` | `POST /distinct/:field` → `advancedDistinct` |
|
|
199
|
+
| `subs.<sub>.list` | `GET /:id/<sub>` → `subs.<sub>.basicList` | `POST /:id/<sub>/__query` → `subs.<sub>.advancedList` |
|
|
200
|
+
| `subs.<sub>.read` | `GET /:id/<sub>/:subId` → `subs.<sub>.basicRead` | `POST /:id/<sub>/:subId/__query` → `subs.<sub>.advancedRead` |
|
|
201
|
+
|
|
202
|
+
Model routers support all fourteen top-level keys. Data routers implement only
|
|
203
|
+
list/read: `basicList`, `advancedList`, `basicRead`, and `advancedRead`, including
|
|
204
|
+
both advanced read paths. Each model subdocument field supports four nested
|
|
205
|
+
list/read keys. Filtered count/distinct POSTs are advanced even without `__query`.
|
|
206
|
+
|
|
207
|
+
Unpaired routes retain their base guards:
|
|
208
|
+
|
|
209
|
+
| Endpoint | Guard |
|
|
210
|
+
| --------------------------------------------- | ------------------- |
|
|
211
|
+
| `GET /new` | `new` |
|
|
212
|
+
| `DELETE /:id` | `delete` |
|
|
213
|
+
| `POST /:id/<sub>` | `subs.<sub>.create` |
|
|
214
|
+
| `PATCH /:id/<sub>`, `PATCH /:id/<sub>/:subId` | `subs.<sub>.update` |
|
|
215
|
+
| `DELETE /:id/<sub>/:subId` | `subs.<sub>.delete` |
|
|
216
|
+
|
|
217
|
+
### Variant and base fallback
|
|
218
|
+
|
|
219
|
+
1. Read the exact variant from the owning runtime (model lookup includes the
|
|
220
|
+
runtime's exact model-default path).
|
|
221
|
+
2. Only if that value is `undefined`, use the existing base operation resolution:
|
|
222
|
+
exact base rule → `operationAccess.default` → scalar `operationAccess` shorthand.
|
|
223
|
+
If no valid guard resolves, deny.
|
|
224
|
+
|
|
225
|
+
The selected variant **replaces** the base guard; the two are not combined or both
|
|
226
|
+
evaluated. Explicit false, an empty array, or a guard returning false is terminal.
|
|
227
|
+
Omitted and explicit undefined variants inherit. With no runtime-default variants
|
|
228
|
+
or other fallback configured:
|
|
229
|
+
|
|
230
|
+
| Rules | Basic list | Advanced list | Root list entry |
|
|
231
|
+
| ---------------------------------- | ---------- | ------------- | --------------- |
|
|
232
|
+
| `list: true` | allowed | allowed | allowed |
|
|
233
|
+
| `list: false` | denied | denied | denied |
|
|
234
|
+
| `list: true, basicList: false` | denied | allowed | allowed |
|
|
235
|
+
| `list: true, advancedList: false` | allowed | denied | allowed |
|
|
236
|
+
| `list: false, basicList: true` | allowed | denied | denied |
|
|
237
|
+
| only `basicList: true` | allowed | denied | denied |
|
|
238
|
+
| `list: true, basicList: undefined` | allowed | allowed | allowed |
|
|
239
|
+
|
|
240
|
+
All rule values use `Validation`: `boolean | string | string[] | GuardHook`.
|
|
241
|
+
Space-separated permission strings AND their names; arrays OR their entries.
|
|
242
|
+
Hooks receive `AccessRouterPermissions`, run with `this` bound to the request,
|
|
243
|
+
and may return a promise. Thrown/rejected operational errors retain normal error
|
|
244
|
+
handling rather than permission fallback.
|
|
245
|
+
|
|
246
|
+
<!-- doc-example: partial -->
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import acl, { type ModelRouterOptions } from '@web-ts-toolkit/access-router';
|
|
250
|
+
|
|
251
|
+
acl.setGlobalOptions({
|
|
252
|
+
globalPermissions(req) {
|
|
253
|
+
return req.headers.user === 'admin' ? ['isAdmin', 'canReadDetails'] : [];
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
const permissionBased = {
|
|
257
|
+
operationAccess: {
|
|
258
|
+
list: true,
|
|
259
|
+
read: true,
|
|
260
|
+
advancedList: 'isAdmin',
|
|
261
|
+
advancedRead: ['canReadDetails', 'isAdmin'],
|
|
262
|
+
advancedUpdate: async function (permissions) {
|
|
263
|
+
return permissions.has('isAdmin') && this.headers['x-write-mode'] === 'advanced';
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
permissionSchema: { name: { list: true, read: true } },
|
|
267
|
+
} satisfies ModelRouterOptions;
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### Shorthand and model defaults
|
|
271
|
+
|
|
272
|
+
`operationAccess: true` (or another scalar `Validation`) supplies the ordinary
|
|
273
|
+
base fallback. In a rule object, use `default` for that fallback:
|
|
274
|
+
|
|
275
|
+
<!-- doc-example: partial -->
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import acl, { type ModelRouterOptions } from '@web-ts-toolkit/access-router';
|
|
279
|
+
|
|
280
|
+
const shorthand = { operationAccess: 'canUseApi' } satisfies ModelRouterOptions;
|
|
281
|
+
const withDefault = {
|
|
282
|
+
operationAccess: { default: 'canUseApi', list: true, basicRead: false },
|
|
283
|
+
} satisfies ModelRouterOptions;
|
|
284
|
+
|
|
285
|
+
acl.setDefaultModelOptions({ operationAccess: { list: true, read: true } });
|
|
286
|
+
acl.setDefaultModelOption('operationAccess.advancedRead', 'isAdmin');
|
|
287
|
+
const defaultRead = acl.getDefaultModelOption('operationAccess.advancedRead');
|
|
288
|
+
const defaultsSnapshot = acl.getDefaultModelOptions();
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Model options use shallow assignment. Supplying an `operationAccess` object
|
|
292
|
+
replaces that stored object rather than recursively merging default siblings.
|
|
293
|
+
Exact variant lookup separately falls back to the runtime's **same exact path**:
|
|
294
|
+
a runtime-default `advancedRead` can therefore outrank a model's `read`, `default`,
|
|
295
|
+
or scalar shorthand unless the model defines its own `advancedRead`.
|
|
296
|
+
|
|
297
|
+
Defaults copied into a model's stored options stay there until updated. Changing
|
|
298
|
+
runtime defaults does not broadcast replacements to existing model values;
|
|
299
|
+
setting a stored variant to undefined can expose a live runtime-default variant
|
|
300
|
+
before base fallback. `getDefaultModelOption` reads the requested path exactly;
|
|
301
|
+
it does not resolve variant/base aliases or `operationAccess.default`.
|
|
302
|
+
`getDefaultModelOptions()` returns a frozen snapshot. Data routers use only their
|
|
303
|
+
own options and never inherit model defaults.
|
|
304
|
+
|
|
305
|
+
### Subdocument field precedence
|
|
306
|
+
|
|
307
|
+
For a basic list of `comments`, resolve:
|
|
308
|
+
|
|
309
|
+
1. Exact `subs.comments.basicList`.
|
|
310
|
+
2. Exact `subs.comments.list`.
|
|
311
|
+
3. Defined `subs.comments`: evaluate a scalar guard; an object without the
|
|
312
|
+
applicable key **denies**, even if top-level list/variants allow.
|
|
313
|
+
4. Only for an absent field rule, top-level exact `basicList`.
|
|
314
|
+
5. Existing top-level base `list` fallback.
|
|
315
|
+
|
|
316
|
+
The same ordering applies to advanced list and both read variants. No nested
|
|
317
|
+
`.default` fallback is introduced. Exact field lookups retain model-default
|
|
318
|
+
specificity; an inherited defined field also stays closed. The legacy scalar `subs` umbrella is still
|
|
319
|
+
accepted, but **does not guard absent fields**; their fallback bypasses it and
|
|
320
|
+
uses top-level rules. Use explicit per-field rules to guard a field.
|
|
321
|
+
|
|
322
|
+
<!-- doc-example: partial -->
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
import type { ModelRouterOptions } from '@web-ts-toolkit/access-router';
|
|
326
|
+
|
|
327
|
+
const subdocumentRules = {
|
|
328
|
+
operationAccess: {
|
|
329
|
+
list: true,
|
|
330
|
+
read: true,
|
|
331
|
+
subs: {
|
|
332
|
+
comments: { list: true, read: true, basicList: false, advancedRead: 'isAdmin' },
|
|
333
|
+
// Closed object: both items list routes are denied despite top-level list: true.
|
|
334
|
+
items: { read: true },
|
|
335
|
+
attachments: 'isAdmin',
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
} satisfies ModelRouterOptions;
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Live updates and option snapshots
|
|
342
|
+
|
|
343
|
+
Use setters to change request-time policy without reconstructing the router:
|
|
344
|
+
|
|
345
|
+
<!-- doc-example: partial -->
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
import acl from '@web-ts-toolkit/access-router';
|
|
349
|
+
|
|
350
|
+
// User must already be registered with this runtime.
|
|
351
|
+
const router = acl.createRouter('User', {
|
|
352
|
+
basePath: '/users',
|
|
353
|
+
operationAccess: { list: true, read: true },
|
|
354
|
+
permissionSchema: { name: true },
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
router.operationAccess('basicRead', false); // Update one key, keep list/read rules.
|
|
358
|
+
router.operationAccess('subs.comments.basicRead', false);
|
|
359
|
+
router.set('operationAccess.advancedList', 'isAdmin');
|
|
360
|
+
router.setOption('operationAccess.basicRead', undefined); // Restore inheritance.
|
|
361
|
+
acl.setModelOption('User', 'operationAccess.basicRead', false);
|
|
362
|
+
|
|
363
|
+
// All object forms replace the whole operationAccess object, not a deep merge.
|
|
364
|
+
router.operationAccess({ list: true, read: true, basicRead: false });
|
|
365
|
+
router.setOptions({ operationAccess: { list: true, read: true, advancedList: false } });
|
|
366
|
+
|
|
367
|
+
const current = router.runtime.getModelOptions('User');
|
|
368
|
+
const exactVariant = router.runtime.getExactModelOption('User', 'operationAccess.basicRead');
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
`set({ operationAccess: ... })` and `setOption('operationAccess', ...)` have the
|
|
372
|
+
same shallow replacement semantics. Dotted paths are setter arguments, not flat
|
|
373
|
+
properties in constructor options. Updating a nested path under a scalar shorthand
|
|
374
|
+
starts an object; retain a `default` rule if you want the prior broad fallback.
|
|
375
|
+
Use `set`/`setOption` with undefined to clear a variant; the two-argument property
|
|
376
|
+
helper is intended for defined values.
|
|
377
|
+
|
|
378
|
+
`router.options` is the frozen **construction-time** snapshot. Options getter
|
|
379
|
+
results are also frozen snapshots; fetch again for current stored values. Ordinary
|
|
380
|
+
`getModelOption`/`getDataOption` use generic nested-option fallback, not the
|
|
381
|
+
route-specific variant-to-base resolver. Exact getters inspect the variant path
|
|
382
|
+
(including exact model-default fallback). Request guards read live runtime values.
|
|
383
|
+
Data router setters use the same forms; its owning runtime provides
|
|
384
|
+
`setDataOption`, `getDataOptions`, and `getExactDataOption`.
|
|
385
|
+
|
|
386
|
+
### Route guards and secondary policy
|
|
387
|
+
|
|
388
|
+
- Field grants, row filters, validation/prepare/decorate hooks, and related target
|
|
389
|
+
checks retain base accesses. A variant grant does not grant fields or bypass
|
|
390
|
+
target authorization.
|
|
391
|
+
- Model `_read`/`_readFilter` misses with `tryList: true` still require **base
|
|
392
|
+
`list`** to retry. `list: false, advancedList: true` cannot grant retry;
|
|
393
|
+
`list: true, basicList: false` cannot block it. Forbidden/BadRequest never retry.
|
|
394
|
+
- Upsert keeps its create/update branch policy; authorizing `advancedUpsert` does
|
|
395
|
+
not also require `advancedCreate` or `advancedUpdate`.
|
|
396
|
+
- Subdocument mutation-response visibility uses base parent/subdocument read/list
|
|
397
|
+
checks. Disabling basic reads does not hide otherwise readable output. Base
|
|
398
|
+
response denial can still yield a successful null/empty response after a write
|
|
399
|
+
(see [Subdocument writes with hidden responses](#subdocument-writes-with-hidden-responses)).
|
|
400
|
+
- Root batches use existing operation names and target base entry checks, with
|
|
401
|
+
entry 401 inside the HTTP 200 batch envelope. Variants are not new root `op`
|
|
402
|
+
values. Direct service calls remain trusted application calls with their
|
|
403
|
+
existing checks; they do not infer basic/advanced access from `req.method`.
|
|
404
|
+
|
|
405
|
+
**Populate selectors:** wire `options.populateAccess` and descriptor `access`
|
|
406
|
+
accept only `'list' | 'read'` (`PopulateAccess` from `/advanced`). The shared Core
|
|
407
|
+
populate boundary also rejects all fourteen reserved variant names in effective
|
|
408
|
+
option/descriptor access, including generic options forwarded by legacy includes
|
|
409
|
+
and subqueries, before target persistence. Other trusted, non-reserved custom
|
|
410
|
+
access strings keep their existing runtime behavior. Invalid selectors return
|
|
411
|
+
controlled BadRequest (direct HTTP 400; affected root entry 400/`bad_request`).
|
|
412
|
+
Legacy read/list includes now propagate target BadRequest instead of silently
|
|
413
|
+
omitting it, so nested invalid selectors cannot disappear from the result.
|
|
414
|
+
|
|
415
|
+
### Runnable data-router split
|
|
416
|
+
|
|
417
|
+
This database-free example exercises the same four data keys after installation:
|
|
418
|
+
|
|
419
|
+
<!-- doc-example: complete-runtime -->
|
|
420
|
+
|
|
421
|
+
```ts
|
|
422
|
+
import { once } from 'node:events';
|
|
423
|
+
import express from 'express';
|
|
424
|
+
import { createAccessRuntime } from '@web-ts-toolkit/access-router';
|
|
425
|
+
|
|
426
|
+
const runtime = createAccessRuntime();
|
|
427
|
+
const router = runtime.createDataRouter('VariantFruit', {
|
|
428
|
+
basePath: '/fruit',
|
|
429
|
+
idParam: 'id',
|
|
430
|
+
idField: 'id',
|
|
431
|
+
data: [{ id: 'apple', name: 'Apple' }],
|
|
432
|
+
operationAccess: { list: true, read: true, basicRead: false },
|
|
433
|
+
permissionSchema: { id: true, name: true },
|
|
434
|
+
});
|
|
435
|
+
const app = express();
|
|
436
|
+
app.use(express.json());
|
|
437
|
+
app.use(router.routes);
|
|
438
|
+
const server = app.listen(0, '127.0.0.1');
|
|
439
|
+
try {
|
|
440
|
+
await once(server, 'listening');
|
|
441
|
+
const address = server.address();
|
|
442
|
+
if (!address || typeof address === 'string') throw new Error('Expected a TCP address');
|
|
443
|
+
const url = `http://127.0.0.1:${address.port}/fruit`;
|
|
444
|
+
const basic = await fetch(`${url}/apple`);
|
|
445
|
+
const advanced = await fetch(`${url}/__query/apple`, {
|
|
446
|
+
method: 'POST',
|
|
447
|
+
headers: { 'content-type': 'application/json' },
|
|
448
|
+
body: '{}',
|
|
449
|
+
});
|
|
450
|
+
if (basic.status !== 401 || advanced.status !== 200) throw new Error('Read split failed');
|
|
451
|
+
const fruit = await advanced.json();
|
|
452
|
+
if (fruit.name !== 'Apple') throw new Error('Expected the authorized fruit');
|
|
453
|
+
router.operationAccess('basicRead', true);
|
|
454
|
+
if ((await fetch(`${url}/apple`)).status !== 200) throw new Error('Live update failed');
|
|
455
|
+
} finally {
|
|
456
|
+
await new Promise<void>((resolve, reject) => {
|
|
457
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
## Basic model GET queries
|
|
463
|
+
|
|
464
|
+
`GET /users?select=name&sort=name%20-createdAt` projects `name` and sorts by
|
|
465
|
+
name ascending, then createdAt descending. GET list `sort` is a single
|
|
466
|
+
space-separated signed-field string; `-` means descending. Sorting runs before
|
|
467
|
+
pagination and can use permitted fields omitted from `select`. Malformed or
|
|
468
|
+
disallowed sort fields return HTTP 400 before the model query executes.
|
|
469
|
+
|
|
470
|
+
Model options `sortableFields` allow additional sorting fields without granting
|
|
471
|
+
output visibility, and `stripDisallowedSort: true` drops disallowed sort keys
|
|
472
|
+
instead of rejecting the request (malformed fields still fail). When no sort is
|
|
473
|
+
supplied, normal model service defaults apply; `?sort=` explicitly disables them.
|
|
474
|
+
GET-by-id reads also accept `select` as a query param.
|
|
475
|
+
|
|
476
|
+
Two router options lock down permission metadata regardless of client input or
|
|
477
|
+
per-operation `defaults`: `stripPermissionsField: true` removes the whole
|
|
478
|
+
permissions field from outputs (equivalent to `includePermissions: false` with
|
|
479
|
+
the field — including its placeholder — deleted; enforcement still runs), and
|
|
480
|
+
`disableFieldPermissions: true` never computes `_view`/`_edit` maps while still
|
|
481
|
+
honoring `includePermissions` for `docPermissions`-hook keys.
|
|
140
482
|
|
|
141
483
|
## Main Exports
|
|
142
484
|
|
|
@@ -152,6 +494,7 @@ Root entrypoint (`@web-ts-toolkit/access-router`):
|
|
|
152
494
|
- `permissionsPlugin` (src/plugins.ts) — Mongoose schema plugin used as `schema.plugin(permissionsPlugin, { modelName })`
|
|
153
495
|
- logger helpers: `redactFilter`, `redactPayload`, `safeStringify`, `isLevelEnabled` + `OpLogContext` type
|
|
154
496
|
- option helpers: `setGlobalOptions`, `setGlobalOption`, `getGlobalOptions`, `getGlobalOption`, `setModelOptions`, `setModelOption`, `getModelOptions`, `getModelOption`, `getModelNames`, `getModelJsonSchema`, `setDefaultModelOptions`, `setDefaultModelOption`, `getDefaultModelOptions`, `getDefaultModelOption`
|
|
497
|
+
- route-policy types: `OperationAccess`, `SubOperationAccess`, `Validation`, `GuardHook`, `RouteVariantAccess`, `ModelRouterOptions`, `DefaultModelRouterOptions`, `DataRouterOptions`, `ExtendedModelRouterOptions`, `ExtendedDefaultModelRouterOptions`, and `ExtendedDataRouterOptions` for typed setters; `PermissionSchema` / `FieldOperationAccess` are separate base-operation field grants. These types are also available from `/advanced`.
|
|
155
498
|
|
|
156
499
|
Subpath entrypoints:
|
|
157
500
|
|
|
@@ -475,6 +818,191 @@ because its output is empty.** Actual write denials and validation/persistence f
|
|
|
475
818
|
retain their errors. Response visibility needs at most one extra parent query and
|
|
476
819
|
resolves applicable row policies once per mutation, rather than once per returned row.
|
|
477
820
|
|
|
821
|
+
## Virtuals (computed fields)
|
|
822
|
+
|
|
823
|
+
Package-level computed output fields ("virtuals") are async, request-aware
|
|
824
|
+
derived fields that behave like first-class response fields. The persisted
|
|
825
|
+
model below has **no** `fullAddress` field; the virtual defines it, the
|
|
826
|
+
permission schema authorizes it, `select` requests it, and the selected output
|
|
827
|
+
keeps it optional (authorization, absent dependencies, `undefined`, and
|
|
828
|
+
fail-closed errors can all omit it).
|
|
829
|
+
|
|
830
|
+
Canonical import shape: default `acl` for the runtime API (named
|
|
831
|
+
`createAccessRuntime` for an isolated runtime); virtual output types live on
|
|
832
|
+
the existing `./advanced` barrel — no new subpaths.
|
|
833
|
+
|
|
834
|
+
<!-- doc-example: partial -->
|
|
835
|
+
|
|
836
|
+
```ts
|
|
837
|
+
import acl, { type ModelRouterOptions } from '@web-ts-toolkit/access-router';
|
|
838
|
+
import type { SelectedPublicOutput } from '@web-ts-toolkit/access-router/advanced';
|
|
839
|
+
|
|
840
|
+
interface User {
|
|
841
|
+
name: string;
|
|
842
|
+
address: string;
|
|
843
|
+
}
|
|
844
|
+
interface UserVirtuals {
|
|
845
|
+
fullAddress: string;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// The persisted model lacks fullAddress; the virtual computes it.
|
|
849
|
+
const options: ModelRouterOptions<User, UserVirtuals> = {
|
|
850
|
+
basePath: '/users',
|
|
851
|
+
permissionSchema: {
|
|
852
|
+
name: { read: true },
|
|
853
|
+
address: { read: 'canViewAddress' },
|
|
854
|
+
fullAddress: { read: 'canViewAddress' },
|
|
855
|
+
},
|
|
856
|
+
virtuals: {
|
|
857
|
+
fullAddress: {
|
|
858
|
+
dependsOn: ['address'],
|
|
859
|
+
read: async function (doc) {
|
|
860
|
+
if (doc.address === undefined) return undefined;
|
|
861
|
+
return `addr:${doc.address}`;
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
// Selected output keeps the virtual optional: present when authorized and
|
|
868
|
+
// computed, omitted otherwise.
|
|
869
|
+
type Selected = SelectedPublicOutput<User, ['name', 'fullAddress'], UserVirtuals>;
|
|
870
|
+
const present: Selected = { name: 'Ada', fullAddress: 'addr:a1' };
|
|
871
|
+
const omitted: Selected = { name: 'Ada' };
|
|
872
|
+
// Pass options to acl.createRouter(UserModel, options); request with
|
|
873
|
+
// select: ['name', 'fullAddress']. Live updates use router.virtuals({...})
|
|
874
|
+
// or router.set('virtuals.fullAddress.read', { get, dependsOn }).
|
|
875
|
+
void [options, present, omitted];
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
There is one public `select` for persisted and virtual fields; the package
|
|
879
|
+
derives internal fetch vs output plans. `dependsOn` names are top-level
|
|
880
|
+
persisted fields relative to the definition's scope (dotted paths and
|
|
881
|
+
virtual-to-virtual dependencies are rejected). Dependencies are fetched
|
|
882
|
+
internally even when the caller may not receive them, then stripped unless
|
|
883
|
+
independently in the original authorized/requested output. Unknown persisted
|
|
884
|
+
getter fields are type errors (and configuration rejections at runtime).
|
|
885
|
+
Virtual names are never added to persisted `Filter`, sort/distinct inputs, or
|
|
886
|
+
client write types.
|
|
887
|
+
|
|
888
|
+
Lean/hydrated parity: getters always receive an isolated, stable plain-object
|
|
889
|
+
view (`toObject({ virtuals: false })` for documents, recursively isolated
|
|
890
|
+
copies for lean results, with `Date`/`Buffer`/`ObjectId` cloned by their own
|
|
891
|
+
constructors), so package virtuals compute for both lean and hydrated results.
|
|
892
|
+
Only returned field values are committed via document-aware helpers;
|
|
893
|
+
`undefined` omits the field; a throw omits it fail-closed with a structural
|
|
894
|
+
log (no raw values or secret-bearing text). Sibling virtual values are never
|
|
895
|
+
visible regardless of completion order, and mutating getter input cannot change
|
|
896
|
+
response data or lifecycle snapshots. Related documents finalize before parent
|
|
897
|
+
getters run, and embedded children finalize before their parent getter.
|
|
898
|
+
|
|
899
|
+
Coverage: every document-bearing public model output uses its own model's
|
|
900
|
+
definitions, permissions, selection, and effective access — direct
|
|
901
|
+
list/read/create/update/upsert/`new`-template outputs, Mongoose `populate`
|
|
902
|
+
targets, legacy + correlated `include` targets (including nested includes),
|
|
903
|
+
and embedded-subdocument outputs. Embedded scopes mirror
|
|
904
|
+
`permissionSchema.<field>.sub` (for example `contacts.sub.displayName` with
|
|
905
|
+
`virtuals.contacts.sub.displayName`), support nested arrays/single-nested
|
|
906
|
+
objects, and reuse the owning parent's internally computed grants; dedicated
|
|
907
|
+
`listSub`/`readSub`/`createSub`/`updateSub`/`bulkUpdateSub` routes finalize
|
|
908
|
+
visible rows only (hidden rows never run getters; denied post-save mutation
|
|
909
|
+
rows stay `[]`/`null`). Populate and include targets are trimmed with the
|
|
910
|
+
target model's policy — including registered targets without virtual
|
|
911
|
+
definitions. `select` keeps its arbitrary-string grammar (`?select=name,
|
|
912
|
+
fullAddress`, space/repeated query forms, body arrays/objects, and
|
|
913
|
+
`['-fullAddress']` exclusions all work); `select` on `populate` entries and
|
|
914
|
+
embedded fields follows the same rule.
|
|
915
|
+
|
|
916
|
+
Access matrix (`context.operation` always carries the initiating public
|
|
917
|
+
operation; the three accesses carry effective visibility):
|
|
918
|
+
|
|
919
|
+
| Output path | `virtualAccess` | `outputAccess` | grants source |
|
|
920
|
+
| ------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ------------------------------------ |
|
|
921
|
+
| list | `list` | `list` | `list` |
|
|
922
|
+
| read / readFilter (incl. `read` using `list` fallback: all three become `list`) | `read` (or fallback `list`) | same | same |
|
|
923
|
+
| create / create branch of upsert | `create` | `read` | `create` |
|
|
924
|
+
| update / update branch of upsert | `update` | `read` | `update` |
|
|
925
|
+
| new template | `create` | `create` | `create` |
|
|
926
|
+
| populated target | target's effective `read`/`list` | same target access | same target access |
|
|
927
|
+
| included target (legacy + correlated, incl. nested) | include `op` (`read`/`list`) | same target access | same target access |
|
|
928
|
+
| embedded values inside a model output | owning output's getter access | owning output's field-policy access | owning document's internal grants |
|
|
929
|
+
| listSub / readSub | `list`/`read` | `list`/`read` | owning-parent internal `read` grants |
|
|
930
|
+
| createSub / updateSub / bulkUpdateSub response (existing rows post-save) | initiating `create`/`update`/`update` | `read` | owning-parent internal `read` grants |
|
|
931
|
+
|
|
932
|
+
A create-only getter runs only on create/upsert-create responses; an
|
|
933
|
+
update-only getter only on update/upsert-update responses. On other responses
|
|
934
|
+
the name stays a registered virtual: omitted from output and never entering
|
|
935
|
+
persisted projections, write admission, or sort/filter/distinct handling. A
|
|
936
|
+
read-denied virtual (explicit deny, or document grants fail the rule) never
|
|
937
|
+
runs its getter on any path and fetches nothing for it; a document-dependent
|
|
938
|
+
rule defers to post-fetch evaluation with real grants.
|
|
939
|
+
|
|
940
|
+
Output-only database/write restrictions: registered virtual names for any
|
|
941
|
+
access (including bare `permissionSchema: true` rules and inapplicable
|
|
942
|
+
descriptors) are excluded from persisted projections, client create/update
|
|
943
|
+
admission (including whole-object/array grants and permissive/Mixed schemas),
|
|
944
|
+
and database sort/filter/distinct field authorization at the model-aware
|
|
945
|
+
service boundary. `delete` (identity only), `exists`, `distinct`, `count`, and
|
|
946
|
+
`countTrusted` never run getters. No submitted or computed virtual value is
|
|
947
|
+
persisted.
|
|
948
|
+
|
|
949
|
+
Trusted `decorate`/`decorateAll`/tasks boundary: virtuals evaluate before
|
|
950
|
+
`decorate`/`decorateAll`/tasks; internal-only dependency fields are stripped
|
|
951
|
+
before those hooks see the object. Trusted hooks may deliberately construct new
|
|
952
|
+
output from trusted context; no second authorization pass after those hooks is
|
|
953
|
+
promised.
|
|
954
|
+
|
|
955
|
+
Performance (measured, VIRT-11): prefer authorized finalized populate/include
|
|
956
|
+
data over per-row database calls. On a 24-row list with 2 getters at
|
|
957
|
+
`maxHookConcurrency: 4`, a virtual doing `Address.findById` per document issued
|
|
958
|
+
25 driver reads (1 list + 24 per-row) with peak active getter work 4; the same
|
|
959
|
+
label via `populate: [{ path: 'addrId', select: ['label'] }]` and a getter
|
|
960
|
+
reading the finalized `doc.addrId` object issued 2 driver reads (batched `$in`)
|
|
961
|
+
with identical output and the same peak bound. `maxHookConcurrency` (default
|
|
962
|
+
`10`) is a per-request/per-runtime ceiling on concurrently awaited getter +
|
|
963
|
+
row-finalization orchestration only — not a per-row multiplier, not leaf
|
|
964
|
+
persistence admission, not a process-wide connection limit; trusted getter
|
|
965
|
+
DB/network I/O runs outside the leaf persistence ceiling. Bounds limit
|
|
966
|
+
simultaneous work, not query counts: they do not collapse N+1 into 1.
|
|
967
|
+
Nested/related/embedded work shares the same bounded scheduling (limit-1
|
|
968
|
+
completes without deadlock; input order is preserved). Parent getters see only
|
|
969
|
+
finalized related/embedded outputs and must not rely on a related model's
|
|
970
|
+
stripped private fields.
|
|
971
|
+
|
|
972
|
+
### Virtuals compatibility notes
|
|
973
|
+
|
|
974
|
+
These notes describe approved external output/validation behavior; they are
|
|
975
|
+
not silent backward compatibility:
|
|
976
|
+
|
|
977
|
+
- Target-model populate trimming now applies to populated documents,
|
|
978
|
+
including registered targets without virtual definitions (previously only
|
|
979
|
+
query-level `select`/`match` restricted populate output, while includes
|
|
980
|
+
trimmed via target `Service.find`/`findOne`). Populated responses may now
|
|
981
|
+
omit fields that previously leaked (for example `alwaysSelect`-forced
|
|
982
|
+
internal fields). This is an intended behavior change.
|
|
983
|
+
- Selection grammar is unchanged: arbitrary select strings remain accepted
|
|
984
|
+
(no stricter signed-field/path grammar was adopted), so there is no
|
|
985
|
+
malformed-path rejection guarantee and no grammar compatibility break.
|
|
986
|
+
Registered-but-inapplicable virtual names remain virtual.
|
|
987
|
+
- Embedded recursion applies the scope's `permissionSchema.<field>.sub` rules
|
|
988
|
+
with children finalized before parents; database fetches retain only the
|
|
989
|
+
needed containers/dependencies (never `.sub` definition paths, and never
|
|
990
|
+
both a container and its child path in one projection, avoiding path
|
|
991
|
+
collisions). An omitted parent container is never exposed just because a
|
|
992
|
+
child virtual uses it internally.
|
|
993
|
+
- Mutation/new selection behavior is unchanged: effective virtual selection is
|
|
994
|
+
computed before evaluation and carried into the shared finalizer; public
|
|
995
|
+
`_create` picks by explicit `select` after decorate/tasks and `_update`
|
|
996
|
+
picks by explicit `select` (else the implicit `returningAll: false` pick);
|
|
997
|
+
`new()` continues to ignore `args.select` (all applicable `create` virtuals
|
|
998
|
+
are considered, subject to explicit exclusion). Any future honoring of
|
|
999
|
+
`new.args.select` is an explicit contract change, not applied here.
|
|
1000
|
+
- Release notes for this feature are generated from conventional commits via
|
|
1001
|
+
`pnpm changelog` (`repo-toolkit-changelog`) into the workspace
|
|
1002
|
+
`CHANGELOG.md`; that generated file is intentionally not hand-edited in this
|
|
1003
|
+
task (per repository constraint). The compatibility notes above live in this
|
|
1004
|
+
README section as the shipped consumer record.
|
|
1005
|
+
|
|
478
1006
|
## Persistence concurrency and resource limits
|
|
479
1007
|
|
|
480
1008
|
`requestComplexity.maxBulkConcurrency` (default `10`) bounds simultaneous awaited
|