@web-ts-toolkit/access-router 0.51.0 → 0.52.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 CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ACL-aware Express routers and in-memory data services for Mongoose-backed APIs.
4
4
 
5
+ **Start here:** [Quick Start](#quick-start) · [Imports](#import-styles) ·
6
+ [Configuration layers](#configuration-layers) · [Public exports](#main-exports)
7
+
8
+ **Guide:** [Operation guards](#basic-and-advanced-operation-access) ·
9
+ [Data routers](#data-routers) · [Root batches](#root-batch-bounds) ·
10
+ [Runtime isolation](#default-runtime-vs-isolated-runtime) ·
11
+ [Validation and OpenAPI](#request-validation-and-openapi)
12
+
13
+ **Business contracts:** [Service authorization](#trusted-service-authorization) ·
14
+ [Mutation input](#independent-mutation-input-authority) ·
15
+ [Subdocuments](#dedicated-subdocument-authorization) ·
16
+ [Includes and counts](#include-cardinality) · [Correlated includes](#correlated-includes) ·
17
+ [Virtuals](#virtuals-computed-fields) · [Hook admission](#hook-admission-and-reentrant-service-calls) ·
18
+ [Persistence limits](#persistence-concurrency-and-resource-limits)
19
+
20
+ **Migration:** [Removed overrides](#breaking-release-service-overrides-migration) ·
21
+ [Named helpers](#breaking-release-named-helpers-and-default-behavior) ·
22
+ [Distinct values](#distinct-whole-value-request-authorization) ·
23
+ [Numeric grouped counts](#numeric-grouped-count-service-api-breaking-migration) ·
24
+ [`skim`](#skim-compatibility-no-op)
25
+
5
26
  ## Installation
6
27
 
7
28
  ```sh
@@ -13,97 +34,1344 @@ Peer dependencies:
13
34
  - `express >= 5`
14
35
  - `mongoose >= 8`
15
36
 
16
- ## Highlights
37
+ Use Node.js 22 or newer. The package ships bundled CommonJS/ESM entrypoints and
38
+ matching declarations; prefer default `acl` for the shared runtime or named
39
+ `createAccessRuntime` for isolation.
17
40
 
18
- - generated model CRUD routers
19
- - generated in-memory data routers
20
- - access control, field permissions, and request-time hooks
21
- - root batch router for grouped operations
22
- - request validation adapters
23
- - generated OpenAPI JSON and Swagger UI routes
41
+ TypeScript applications also need `typescript`, `@types/node`, and `@types/express`
42
+ as development dependencies. MongoDB must be reachable for model operations;
43
+ in-memory data routers need no database connection. Install an external validator
44
+ such as AJV only when using its adapter.
24
45
 
25
46
  ## Quick Start
26
47
 
48
+ A public-read catalog needs a model, route guards, and field rules. Save this as
49
+ `server.mts` and compile with TypeScript/NodeNext, or run it directly with Node 22.18+.
50
+
27
51
  <!-- doc-example: partial -->
28
52
 
29
53
  ```ts
30
54
  import express from 'express';
31
55
  import mongoose from 'mongoose';
32
- import acl, { permissionsPlugin } from '@web-ts-toolkit/access-router';
33
-
34
- // 1. Configure how request permissions are resolved globally.
35
- acl.setGlobalOptions({
36
- requestPermissionField: '_permissions',
37
- // Set to false only when legacy clients populate models without createRouter().
38
- requireRegisteredPopulateModels: true,
39
- globalPermissions(req) {
40
- return req.headers.user === 'admin' ? ['isAdmin'] : [];
41
- },
42
- });
43
-
44
- // 2. Register a Mongoose model and schema. The schema opts in to the
45
- // permissions plugin so generated routers can enforce field access.
46
- const userSchema = new mongoose.Schema({ name: String, role: String, public: Boolean });
47
- userSchema.plugin(permissionsPlugin, { modelName: 'User' });
48
- const UserModel = mongoose.model('User', userSchema);
49
-
50
- // 3. Create routers. Pass either the model name or the mongoose.Model
51
- // instance. Passing the instance preserves an explicit connection
52
- // (useful for multi-connection setups) and registers it with the
53
- // active runtime.
54
- const userRouter = acl.createRouter('User', {
55
- basePath: '/users',
56
- operationAccess: { list: true, create: true, read: true, update: true, delete: true },
57
- permissionSchema: { name: true, role: 'isAdmin', public: true },
58
- });
56
+ import { createAccessRuntime } from '@web-ts-toolkit/access-router';
59
57
 
60
- const fruitRouter = acl.createDataRouter('fruit', {
61
- basePath: '/fruit',
62
- idParam: 'id',
63
- idField: 'id',
58
+ const api = createAccessRuntime();
59
+ const Product = mongoose.model('Product', new mongoose.Schema({ name: String, internalCost: Number }));
60
+ const products = api.createRouter(Product, {
61
+ basePath: '/products',
64
62
  operationAccess: { list: true, read: true },
65
- data: [{ id: 'apple', name: 'Apple', public: true }],
66
- permissionSchema: { id: true, name: 'isAdmin', public: true },
67
- });
68
-
69
- const docsRouter = acl.createOpenApiRouter({
70
- title: 'Example API',
71
- version: '1.0.0',
63
+ permissionSchema: { name: true },
72
64
  });
73
65
 
74
- // 4. Mount routers under an Express app.
75
66
  const app = express();
76
67
  app.use(express.json());
77
- app.use(userRouter.routes);
78
- app.use(fruitRouter.routes);
79
- app.use(docsRouter);
80
-
81
- // 5. Connect to MongoDB before accepting traffic. A failed connection throws
82
- // and exits before the server calls `app.listen`, so the service never
83
- // publishes routes it cannot serve.
84
- const port = Number(process.env.PORT ?? 3000);
85
- const mongoUrl = process.env.MONGODB_URL ?? 'mongodb://localhost:27017/example';
68
+ app.use(products.routes); // includes this runtime's request middleware
69
+
86
70
  try {
87
- await mongoose.connect(mongoUrl);
88
- } catch (err) {
89
- console.error(`Failed to connect to MongoDB at ${mongoUrl}:`, err);
71
+ await mongoose.connect(process.env.MONGODB_URL ?? 'mongodb://127.0.0.1:27017/example');
72
+ } catch (error) {
73
+ console.error(error);
90
74
  process.exit(1);
91
75
  }
76
+ app.listen(3000);
77
+ ```
78
+
79
+ `GET /products?select=name&limit=20` returns `{ data, meta }`; `GET /products/:id`
80
+ returns one readable product. `_id` is retained by default, and `internalCost`
81
+ requires its own field permission. Unconfigured write operations are denied.
82
+ Passing the model instance registers it in this runtime and preserves its Mongoose
83
+ connection. [String-name factories](#createrouter-overloads) need a registered model.
84
+
85
+ For actor-specific rules, resolve grants from your application's authenticated
86
+ server-side actor in `globalPermissions`, then use those permission names in the
87
+ rules. The public-read example needs neither authentication nor a schema plugin.
88
+
89
+ ## Import styles
90
+
91
+ <!-- doc-example: partial -->
92
+
93
+ ```ts
94
+ import acl from '@web-ts-toolkit/access-router'; // preferred shared-runtime API
95
+ import { createAccessRuntime, fromZod } from '@web-ts-toolkit/access-router'; // isolated API / helpers
96
+ import { parseBody, Codes, MIDDLEWARE } from '@web-ts-toolkit/access-router/advanced';
97
+ import { copyAndDepopulate } from '@web-ts-toolkit/access-router/processors';
98
+ ```
99
+
100
+ `import { acl }` is the same value as the default export. In CommonJS use
101
+ `const { acl, createAccessRuntime } = require('@web-ts-toolkit/access-router')`;
102
+ `require(...)` itself is the export object, not the callable `acl` value.
103
+
104
+ | Entrypoint | Use it for |
105
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
106
+ | `@web-ts-toolkit/access-router` | Runtime/router factories, guards, validation adapters, public configuration and authorization types. |
107
+ | `@web-ts-toolkit/access-router/advanced` | Additional request/service/wire types (`ModelRequest`, `RootQueryEntry`, `FindOptions`, `Defaults`, `Include`), enums, symbols and parsers. No runtime factory or standalone `Service` class export. |
108
+ | `@web-ts-toolkit/access-router/processors` | Named document processors and their option/output types; [typed example](#subpath-import-example). |
109
+
110
+ Use package-name imports, not `src/*` or `dist/*` paths. The three entrypoints have
111
+ ESM/CJS JavaScript and matching `.d.mts`/`.d.ts` declarations selected by `exports`.
112
+ See [Main Exports](#main-exports) for the inventory. Acquire services from
113
+ `router.getService(req)` or `req.macl.getService(name)` after the owning middleware
114
+ initializes the request.
115
+
116
+ ## Configuration layers
117
+
118
+ `const api = createAccessRuntime()` returns a callable **runtime-bound API**;
119
+ `api.runtime` is its owning `AccessRuntime`. Default `acl` has the same API over
120
+ `defaultRuntime`. Prefer methods on that API or `router.runtime` when configuring
121
+ a particular runtime. Standalone named option helpers use the active runtime in
122
+ package-managed execution, falling back to `defaultRuntime` outside it.
123
+
124
+ | Layer / public type | Where to put it | Scope and examples |
125
+ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
126
+ | Runtime/global — `GlobalOptions` | `api.setGlobalOptions({...})`, `api.setGlobalOption(key, value)`; `api.set` is an alias for these forms | That runtime's request grants (`globalPermissions`, `requestPermissionField`), logger, registered-populate policy and `requestComplexity`. |
127
+ | Model defaults — `DefaultModelRouterOptions` | `api.setDefaultModelOptions({...})`, `api.setDefaultModelOption(path, value)` | Shared model starting values such as `listHardLimit`, IDs, route segments, `operationAccess` and metadata posture. Data/root routers do not inherit them. |
128
+ | Model router — `ModelRouterOptions<Model, Virtuals>` | `api.createRouter(Model, {...})`, then `router.setOptions`, `router.setOption` / `router.set` | One registered model's guards, `permissionSchema`, hooks, virtuals, request schemas and method defaults. Routers for the same model/runtime share its stored options. |
129
+ | Data router — `DataRouterOptions<Data>` | `api.createDataRouter(name, {...})`, its setters, or `api.runtime.setDataOptions` / `setDataOption` | Owned in-memory `data`, list/read guards, request-only fields/filters and decorators. No model `defaults` or model authorization policy. Set `idParam` and `idField` explicitly for data identifier routes. |
130
+ | Root router — `RootRouterOptions` | Single-argument `api.createRouter({ basePath, operationAccess, ... })` | The batch endpoint's scalar guard and batch/order/whole-operation limits; each target keeps its own policy. |
131
+ | Per-method defaults — `Defaults<Model>` from `/advanced` | `ModelRouterOptions.defaults`, e.g. `defaults.publicListArgs` or `defaults.findOptions` | Query/presentation defaults for the **named method**, not global settings or trusted authority. Public wrappers and internal methods have separate buckets. |
132
+ | Wire query / `args` / `options` | GET query, advanced body, or root entry | Request-controlled selection, sorting, pagination and allowed presentation options; placement and spelling depend on transport (below). |
133
+ | Trusted per-call policy — `ServiceAuthorization`, `MutationAuthorization`, etc. | The internal service's final `options.authorization` | Server-owned, method-specific row/input/output/related authority. Absent dimensions use request authority. Never a router default, HTTP option or reusable permission mutation. |
134
+
135
+ ### Assignment, precedence and capture
136
+
137
+ 1. **Assignment is shallow.** Object setters replace each supplied top-level value
138
+ and keep other top-level keys. `setOptions({ operationAccess: { read: true } })`
139
+ replaces the whole stored guard object; it does not merge its old siblings.
140
+ Model option assignment is `{ ...runtimeModelDefaults, ...storedModelOptions,
141
+ ...suppliedOptions }`. Changing runtime defaults does not broadcast over values
142
+ already stored on a model. Data assignment has no model-default layer.
143
+ 2. **Dotted setters update one path copy-on-write.** Use
144
+ `router.setOption('operationAccess.basicRead', false)` to keep sibling rules,
145
+ or pass `undefined` to restore lookup fallback. Property helpers such as
146
+ `router.operationAccess('basicRead', false)` are for defined values; use
147
+ `set`/`setOption` for undefined. Updating a path beneath a scalar starts an
148
+ object; use a `default` entry to retain a broad fallback. Dotted keys belong in
149
+ setter arguments, not flat constructor-object properties.
150
+ 3. **Lookup is not a deep merge.** Generic nested option/hook lookup uses the
151
+ stored exact path → its parent's `.default` → the parent value (bare hook or
152
+ shorthand). Only `undefined` falls through, not false/null. The generic model
153
+ getter uses the exact runtime-model-default path as its final fallback if the
154
+ stored parent is also absent. `getExactModelOption` instead checks just the
155
+ exact stored path, then the same runtime-default path; `getExactDataOption`
156
+ checks only the stored path. `getDefaultModelOption` is exact. **Route guards
157
+ have their own resolver:** exact variant (including exact model-default
158
+ fallback) → base exact/`.default`/shorthand. A default variant can therefore
159
+ outrank a model's base rule. See the [full precedence](#variant-and-base-fallback),
160
+ including closed [subdocument rules](#subdocument-field-precedence).
161
+ 4. **Method values resolve with `??`.** At each service resolver a non-nullish
162
+ supplied property wins over that method's `defaults` bucket, then its built-in
163
+ fallback. False, zero, `''` and `[]` are supplied values; their normal validation
164
+ and selection meanings still apply. Public `_list`/`_read`/`_create`/`_update`
165
+ resolve `public*Args`/`public*Options` first, then forward resolved values to
166
+ internal methods. A still-undefined query setting can reach the internal bucket;
167
+ an already resolved built-in (such as public list `lean: true`) takes precedence
168
+ over that internal default. By-ID delegations similarly forward resolved values.
169
+ Public `_upsert` uses the public create or update bucket for its branch; trusted
170
+ `upsert` has `upsertArgs`/`upsertOptions`. There is no `publicUpsertOptions` bucket.
171
+ 5. **Metadata posture is absolute.** Model `stripPermissionsField: true` removes
172
+ the entire output metadata field; `disableFieldPermissions: true` suppresses
173
+ `_view`/`_edit` computation. They win over per-call flags and method defaults.
174
+ Internal authorization and full document grants still run.
175
+
176
+ The supported `Defaults` buckets are `findArgs`/`findOptions`,
177
+ `findOneArgs`/`findOneOptions`, `findByIdArgs`/`findByIdOptions`,
178
+ `createArgs`/`createOptions`, `updateOneArgs`/`updateOneOptions`,
179
+ `updateByIdArgs`/`updateByIdOptions`, `upsertArgs`/`upsertOptions`, `existsOptions`,
180
+ and the four `publicList`, `publicRead`, `publicCreate`, `publicUpdate` Args/Options
181
+ pairs. There is no general `defaults.options`, count, or data-method bucket.
182
+
183
+ <!-- doc-example: partial -->
184
+
185
+ ```ts
186
+ import type { ModelRouterOptions } from '@web-ts-toolkit/access-router';
187
+
188
+ const options = {
189
+ operationAccess: { list: true, read: true },
190
+ permissionSchema: { name: true },
191
+ defaults: {
192
+ publicListArgs: { select: ['name'], limit: 20 },
193
+ publicListOptions: { includePermissions: false },
194
+ findOptions: { includeCount: true }, // internal find; public list resolves its own flag
195
+ },
196
+ } satisfies ModelRouterOptions;
197
+ ```
198
+
199
+ `router.options` is a frozen **construction-time snapshot**. Runtime option getters
200
+ return frozen stored snapshots; read them again for current values. Ordinary plain
201
+ option containers are copied/frozen; hook functions, Mongoose models and other
202
+ non-plain handles keep their identities, and the logger is retained by reference.
203
+ Data has the stronger
204
+ [data-specific ownership contract](#in-memory-data-ownership), including detached
205
+ native-valued getter copies.
206
+
207
+ Route guards and request-schema lookups read live policy. A retained model service
208
+ reads its originating runtime's current presentation options/defaults on each call;
209
+ model authorization calls capture
210
+ their current policy/configuration before awaiting hooks and keep it coherent for
211
+ that call. Each document/scope/access stage has separate grants. A data service
212
+ captures its data version at construction. Acquire a new service for replacement
213
+ data. Request permission initialization is reused within its owning request/runtime.
214
+ Base-filter results are reused for the same model/access and policy function;
215
+ replacing that function starts a fresh cache entry. Hook/persistence
216
+ limits are captured together on first work-state use and retained by that owner.
217
+
218
+ Model services and public services retain their originating core, actor, row cache,
219
+ model connection and registry even if another runtime later initializes the same
220
+ Express request. This includes scalar counts/distinct, ID lookups, mutations, includes,
221
+ populate, subdocuments and field helpers. Services acquired later from a retained core
222
+ also belong to that core. The request's current `macl` slot still belongs to the new
223
+ runtime; acquiring a service there uses the new runtime's independent authority.
224
+ Output handles remain bound to their original request/runtime/model/scope and cannot
225
+ be reused by the replacement runtime.
226
+
227
+ Route-shape keys (`modelName`/`dataName`, `basePath`, `parentPath`, `idParam`,
228
+ `queryRouteSegment`, and model `mutationRouteSegment`) are fixed at construction;
229
+ router setters reject them. Runtime storage edits do not rebuild already-mounted
230
+ Express routes. `parentPath` supplies the external prefix for route metadata/logging;
231
+ mount that prefix in Express yourself. Live `idField`/`resolveIdFilter` change lookup
232
+ behavior rather than route shape.
233
+
234
+ ### Wire placement and option names
235
+
236
+ | Intent | Basic GET query | Advanced body | Root entry |
237
+ | ------------------------ | -------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- |
238
+ | Selection / sort | `select=name`, `sort=name%20-createdAt` | `select`, `sort` at body top level | `args.select`, `args.sort` |
239
+ | Pagination | `skip`, `limit`, `page`, `page_size` | `skip`, `limit`, `page`, `pageSize` | Same camelCase keys under `args` |
240
+ | List totals | `include_count=true` | `options.includeCount: true` | `options.includeCount: true` |
241
+ | Extra pagination headers | `include_extra_headers=true` | `options.includeExtraHeaders: true` | No per-entry HTTP-header option |
242
+ | Model metadata | `include_permissions`, `include_field_permissions` | `options.includePermissions`, `options.includeFieldPermissions` | Same camelCase keys under `options` |
243
+ | Read-miss fallback | `try_list` on model identifier GET | `options.tryList` on model read | `options.tryList` on model read |
244
+
245
+ GET flag values are strings `true`/`false`; body/root flags are booleans. The table
246
+ describes list/read fields on routes that support them, not arbitrary forwarding.
247
+ GET lists start with `{}` as their client filter; use an advanced body for `filter`,
248
+ `populate` or `include`. Model GET-by-ID also accepts `select`; basic data GET-by-ID
249
+ does not project query selection (use its advanced read). `lean` is a model service/
250
+ root read-list option, not a generated direct HTTP body option. Mutation bodies use
251
+ top-level `data`/`select`/`populate`/`tasks` and their own allowed `options`; see
252
+ [advanced mutation validation](#advanced-mutation-validation).
253
+
254
+ List responses are `{ data, meta }`. Request `includeCount` explicitly on the wire
255
+ to expose `meta.totalCount`; a service default alone is not an HTTP metadata switch.
256
+ Internal list `count` is returned-row count; `totalCount` is the authorized match
257
+ total when computed. Direct service `find(query, args, options)` keeps the predicate
258
+ separate from both objects. Root entries keep `target`, `name`, base `op`, `filter`/
259
+ `id`/`data` at entry top level; `args` and `options` are separate siblings.
260
+
261
+ ### Names with different jobs
262
+
263
+ | Names | Deliberate distinction |
264
+ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
265
+ | `operationAccess` / `permissionSchema` / `authorization` | Route entry / request field rules / explicit server-only per-call authority. An entry grant does not grant fields or related targets. |
266
+ | `select` / `alwaysSelectFields` / virtual `dependsOn` | Desired output / private persisted hook inputs / scoped getter dependencies. Fetching a value grants no output authority. |
267
+ | `baseFilter` / `overrideFilter` | Request row scope / ordinary-query transform. Trusted rows replace only the former; the transform still runs once and false stays terminal. |
268
+ | `idParam` / `idField` / `resolveIdFilter` | Express parameter name / lookup field / custom identifier-to-filter hook. A callable resolver takes precedence over `idField`; otherwise lookup uses that field (model default `_id`). |
269
+ | `requestPermissionField` / `documentPermissionField` / `docPermissions` | Request grant storage / serialized document metadata path / hook producing full internal document grants. Metadata flags affect exposure, not authority. |
270
+ | `requestSchemas` / `validate` / `prepare` / `transform` | HTTP parsing / admitted model-input validation / trusted data preparation / live-document mutation before save. |
271
+ | `populate` / `include` / `related` | Declared Mongoose references / output-only related query attachments / independent trusted target policy for an exact requested path. |
272
+ | `access` / `populateAccess` | Method-specific effective row/field access / target populate access (`list` or `read` on the wire). Neither chooses a basic/advanced route variant. |
273
+ | `lean` / `skim` / metadata flags | Mongoose hydration choice / deprecated no-op / serialization and field-map choices. All preserve authorization. |
274
+ | `maxConcurrentOperations` / `maxHookConcurrency` / `maxBulkConcurrency` | Whole root operations per order group / request-runtime hook chains / request-runtime leaf persistence operations. Separate counted units and pools. |
275
+ | `count` / `includeCount` / `countByFieldValues` | One exact numeric query result / list match-total option / bounded numeric counts per original key alias. Returned-row counts and overlapping per-key counts are not union totals. |
276
+
277
+ ### `skim`: compatibility no-op
278
+
279
+ `skim` remains accepted in existing service/default and supported wire option slots
280
+ for compatibility, but is **deprecated and has no behavioral effect** in canonical
281
+ model operations. True, false and omission all keep field/row authorization, document
282
+ grants, virtuals and decoration. Remove it from new code. Use `lean` to choose fetch
283
+ hydration, `includePermissions`/`includeFieldPermissions` to choose metadata, or the
284
+ router's absolute metadata postures; none skips authorization. Existing examples
285
+ that retain `skim` below demonstrate this compatibility contract.
286
+
287
+ ### Root batch bounds
288
+
289
+ Create the batch endpoint with the **single-options** overload,
290
+ `api.createRouter({ basePath: '/batch', operationAccess: true })`, and mount its
291
+ `.routes`. Use the same runtime that registered its model/data targets. POST an array:
292
+
293
+ ```json
294
+ [
295
+ {
296
+ "target": "model",
297
+ "name": "Product",
298
+ "op": "list",
299
+ "order": 0,
300
+ "args": { "select": ["name"], "limit": 20 },
301
+ "options": { "includeCount": true }
302
+ }
303
+ ]
304
+ ```
305
+
306
+ `maxBatchEntries` defaults to **100**. `maxOrderGroups` defaults to **25** and applies
307
+ **two** bounds: at most 25 distinct order groups, and every `order` is an integer
308
+ in **0..25 inclusive** (omission is 0). Thus a lone `order: 26` rejects, and using
309
+ all 26 values from 0 through 25 also rejects. `maxConcurrentOperations` defaults
310
+ to **10** whole operations within each group. Constructor limit values must be
311
+ positive safe integers; invalid values fall back to these defaults. Excess batch/
312
+ order input returns BadRequest before entry dispatch.
313
+
314
+ Groups run in ascending order; within a group operations may run concurrently.
315
+ Results retain input indices/order, with each entry's `statusCode` and public
316
+ `result` inside the HTTP 200 batch response. Root entries use base operation guards,
317
+ not basic/advanced variant names. Batches are non-transactional: an unexpected
318
+ rejection can leave already-committed writes. Hook and persistence work also obey
319
+ their separate [request/runtime limits](#persistence-concurrency-and-resource-limits).
320
+
321
+ Runtime `requestComplexity` additionally defaults to `maxDepth: 8`, `maxNodes: 500`,
322
+ `maxLogicalClauses: 50`, `maxInValues: 100`, `maxBulkItems: 100`,
323
+ `maxIncludeCount: 10`, `maxSubQueryCount: 10`, `maxCorrelatedQueries: 100`, and
324
+ `maxCorrelatedDepth: 5`. Both concurrency defaults are 10. These configurable budgets
325
+ normalize invalid/non-positive/unsafe integers to their built-in defaults; zero does
326
+ not disable enforcement. The fixed grouped-count input bounds and 64-scope hook
327
+ reentry bound are separate contracts, documented in their sections.
328
+
329
+ ## Trusted service authorization
330
+
331
+ Use these contracts for tenant-scoped business services, protected input/output,
332
+ related targets and same-execution document decisions. For a complete executable
333
+ example, see [Runnable authorization and handle reuse](#runnable-authorization-and-handle-reuse).
334
+
335
+ ### Public request admission and backend options
336
+
337
+ All authorization input described below is **trusted server-only**. Acquire a
338
+ service through `router.getService(req)` or `req.macl.getService(modelName)` after
339
+ the owning runtime's middleware initializes the request. Keep policy in server
340
+ code; never copy it from a request body/query, OpenAPI option, client or React call.
341
+
342
+ Generated routes, root batches, public service wrappers (`_list`, `_read`,
343
+ `_readFilter`, mutations and `_new`), include/subquery options and populate
344
+ descriptors are request-only. Supplying `authorization`, `overrides`,
345
+ `documentAuthorization`, or private prepared/execution controls in their control
346
+ slots returns BadRequest, including after a custom request validator transforms an
347
+ envelope. Legacy include overrides that were silently ignored now fail admission.
348
+ Unknown options are not forwarded to internal target services.
349
+
350
+ Router `defaults` (including dotted setters) support query/presentation settings,
351
+ not per-call authorization or legacy overrides. Move backend authority out of
352
+ defaults and public/nested request options into explicit internal service calls.
353
+ Direct service row/output/related policies, named output helpers and internal result
354
+ contexts are available. The complete service `overrides` container is removed in
355
+ this breaking release; see the migration table below.
356
+
357
+ These restrictions apply to control slots, not business fields: payloads and
358
+ filters may still contain persisted fields named `authorization` or `overrides`,
359
+ and selections may request those names under ordinary field permissions. The
360
+ configured model `overrideFilter` hook/setter remains supported.
361
+
362
+ For populate and legacy/correlated includes, `authorization.related` is keyed by
363
+ the exact requested relationship/attachment path. Each entry must name the exact
364
+ registered target `model`; unused, ambiguous and mismatched entries reject before
365
+ target persistence. Child `operation`, `rows` and `outputFields` policies are
366
+ independent and default to request authority. A parent relationship-field grant
367
+ permits the source field but does not authorize target fields or operations.
368
+ Nested policies belong under the immediate target's `related` entry. Count targets
369
+ accept only `model`, `operation` and `rows`.
370
+
371
+ Target output grants remain selection-limited, including schema-hidden fields and
372
+ virtuals. Populate match, include filter, ref IDs and join/correlation constraints
373
+ remain ANDed with the target scope after query transformation in every row mode.
374
+ Populate policies also apply after create/update/upsert. Neither parent nor related
375
+ grants flow into request-controlled `$$sq` queries. For a same-execution target pick,
376
+ use `result.context.getDocumentAuthorization(child)` (or the corresponding list-row
377
+ context) with **that target model's** helper. Private dependencies and join keys do
378
+ not become output grants; related copies retain their own document results.
379
+
380
+ The existing `requireRegisteredPopulateModels: false` configuration remains an
381
+ explicit compatibility opt-out for requested unregistered populate targets. Those
382
+ values retain the descriptor's projection, have no target authorization handle,
383
+ and cannot receive `related` policies. It does not make an unassociated object
384
+ passed to a raw-policy helper into authorized target output.
385
+
386
+ ### Selection, grants and document results
387
+
388
+ For business fields, output is **selected AND (request-authorized OR explicitly
389
+ granted)**. `outputFields.mode` is `request` (the default), additive `grant`, or
390
+ `unrestricted` over selected eligible catalog fields. An empty grant adds nothing.
391
+ With omitted select, normal defined request fields plus explicit grant paths are
392
+ candidates; unrestricted uses the captured persisted/registered-virtual catalog.
393
+ Explicit selections/exclusions and `requireExplicitSelect` still apply. Grants
394
+ cannot fabricate missing values or guarantee a required result field exists.
395
+
396
+ Backend model `genSelect(access, select, options)` and its `genQuerySelect` alias
397
+ accept only named output-planning options, for example
398
+ `{ authorization: { outputFields: { mode: 'grant', paths: ['internalNote'] } } }`.
399
+ They return a persisted **fetch projection**, including private identity and
400
+ dependency inputs; this is not an output allowlist. Grants are additive to
401
+ request rules, never add to an explicit selection, and never override an
402
+ exclusion. A container grant covers its selected eligible subtree; a descendant
403
+ grant admits only the minimal ancestor structure, not sibling fields. Optional
404
+ `scopePath: ['contacts']` uses response segments; grant paths remain relative to
405
+ the owning model. The catalog includes declared `select: false` fields, embedded
406
+ schemas and registered virtuals; safe explicitly named Mixed/Map branches are
407
+ supported. Virtual names themselves never enter the database projection.
408
+
409
+ An opaque Mixed/Map root grant covers its existing safe subtree, subject to
410
+ descendant exclusions such as `-payload.secret`. A descendant-only selection or
411
+ grant does not authorize a scalar ancestor value: that value is omitted (or a
412
+ `null` placeholder in an array). A grant to the root itself can authorize its
413
+ scalar value when that root is selected.
414
+
415
+ Planning defers document-dependent rules and never runs permission functions or
416
+ virtual getters. Document hooks receive projected input: configure additional
417
+ persisted hook dependencies with `alwaysSelectFields`. Internal dependency fetches
418
+ do not grant output, even when the same path is also a private include join key.
419
+ Captured row/document hooks retain exact-access → `.default` → bare-hook resolution;
420
+ only an undefined entry falls through. Omitted/undefined helper options now use the
421
+ same canonical request-mode output planner as `{}`; positional helpers are removed.
422
+ Backend model output helpers also accept named options:
423
+ `pickAllowedFields(doc, access, { authorization })`, its `trimOutputFields` alias,
424
+ and `genAllowedFields(doc, access, { authorization, purpose: 'output' })`. Core
425
+ provides the same signatures with `modelName` first. They filter existing values,
426
+ including nested/array paths; they do not apply a selection, compute virtuals,
427
+ restore absent fields, check rows, or authorize writes. Omitted/undefined options
428
+ start fresh request-mode output evaluation. `genAllowedFields` defaults to
429
+ `{ purpose: 'output' }`; an explicitly supplied options object still requires that
430
+ purpose. It reports **existing output paths**, not a schema/input allowlist. Null or
431
+ undefined documents yield `[]` (pick/trim preserve the nullish value) without hooks.
432
+
433
+ The shared finalizer now consumes retained authorization plans and privately
434
+ memoizes full document grants and field decisions. For a subsequent same-execution
435
+ pick, use `{ documentAuthorization }` with the package-issued output context's
436
+ handle, rather than resupplying the raw policy. Handles validate the unchanged
437
+ output identity, request/runtime/model, scope, access and document stage; caller
438
+ spreads, matching IDs and `_permissions` are not provenance. Raw policy on a known
439
+ trimmed output retains unconditional request fields and explicit grants, but does
440
+ not rerun the document hook or recover document-dependent decisions. Metadata
441
+ stripping/exposure switches are independent of internal grants. Target models
442
+ require their own authorization; parent grants cover the relationship only.
443
+
444
+ Internal `findOne`/`findById`/`find`, `create`, `updateOne`/`updateById`, `upsert`
445
+ and `new` now consume the captured call policy throughout output finalization.
446
+ Single results expose `context.documentAuthorization`; list/create results expose
447
+ one `contexts[index]` per row. These opaque handles are non-enumerable context
448
+ properties, never attached to `data`. `context.getDocumentAuthorization(value)`
449
+ looks up an unchanged registered output in that result's graph.
92
450
 
93
- app.listen(port, () => {
94
- console.log(`API listening on http://localhost:${port}`);
451
+ <!-- doc-example: partial -->
452
+
453
+ ```ts
454
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
455
+ declare const req: ModelRequest;
456
+ const service = req.macl.getService<{ status?: string; internalNote?: string }>('Order');
457
+ declare const orderId: string;
458
+ const result = await service.findOne(
459
+ { _id: orderId },
460
+ { select: ['status', 'internalNote'] },
461
+ { authorization: { outputFields: { mode: 'grant', paths: ['internalNote'] } } },
462
+ );
463
+ if (result.success) {
464
+ const picked = await service.pickAllowedFields(result.data, 'read', {
465
+ documentAuthorization: result.context.documentAuthorization,
466
+ });
467
+ console.log(picked);
468
+ }
469
+ ```
470
+
471
+ The selected stored note survives false/missing request rules; `status` still
472
+ requires normal request authorization. Ordinary document grants now apply even
473
+ under `skim` and metadata-disabled/stripped postures. Declare private hook inputs
474
+ with `alwaysSelectFields`; later handle picks reuse the original results after
475
+ those inputs are removed. Metadata describes request permissions, not backend grants.
476
+
477
+ Mutations use ordinary `args.select`. Output grants never admit protected writes;
478
+ validation, prepare/transform/afterPersist/change hooks and save semantics remain.
479
+ Update admission and post-write output have separate document stages. Upsert keeps
480
+ its initiating operation with create/update branch accesses. `new()` continues to
481
+ ignore `select`, including empty/exclusion projections. Trusted presentation hooks
482
+ run after finalization and may intentionally remove/construct output. There is no
483
+ automatic second authorization pass; arbitrary mutations/replacements do not acquire
484
+ provenance. For an unchanged output, use its handle at the **output access**: `read`
485
+ for ordinary reads/create/update/upsert output, `list` for lists or reads using list
486
+ access, and `create` for `new()`.
487
+
488
+ The immutable **call policy** captures authority once for planning and execution.
489
+ Each document/scope/access stage separately owns its hook grants and field decisions;
490
+ two list rows never share those results. Reusing the same raw policy is fresh
491
+ evaluation, not a substitute for a document handle. A fresh unassociated input must
492
+ contain the projected hook dependencies; a recognized trimmed output cannot rerun
493
+ the hook to reconstruct lost decisions. Use either `authorization` or
494
+ `documentAuthorization` in a named helper, never both. When extracting policy from a
495
+ full call, construct `{ outputFields: policy.outputFields }` for output-only helpers;
496
+ `create` accepts input/output/related, and `new` accepts output only.
497
+
498
+ ### Independent mutation input authority
499
+
500
+ Trusted server code can use **`authorization.inputFields`** on model `create`,
501
+ `updateOne`, `updateById`, and both `upsert` branches. `MutationAuthorization<TModel,
502
+ TVirtuals>` adds this dimension to the read policy; `CreateAuthorization` also
503
+ accepts it but rejects `rows`. `ServiceAuthorization` remains the read policy.
504
+ Reads (even with a mutation access name), `new`, output helpers, related read/count
505
+ targets and public options reject input authority, including reusable typed policies.
506
+
507
+ <!-- doc-example: partial -->
508
+
509
+ ```ts
510
+ import type { MutationAuthorization } from '@web-ts-toolkit/access-router';
511
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
512
+ type Order = { status: string; internalNote?: string; contacts: { email: string; ssn: string }[] };
513
+ declare const req: ModelRequest;
514
+ declare const orderId: string;
515
+ const service = req.macl.getService<Order>('Order');
516
+ const authorization = {
517
+ inputFields: { mode: 'grant', paths: ['internalNote', 'contacts.email'] },
518
+ } as const satisfies MutationAuthorization<Order>;
519
+ const result = await service.updateById(
520
+ orderId,
521
+ {
522
+ status: 'reviewed', // ordinary request permission still applies
523
+ internalNote: 'server-owned decision', // explicit input grant
524
+ contacts: [{ email: 'corrected@example.test' }], // patches existing element 0
525
+ },
526
+ { select: ['status', 'internalNote'] },
527
+ { authorization },
528
+ );
529
+ // The note is persisted, but still omitted from output if the actor cannot read it.
530
+ // Add a separate outputFields grant if this call also needs to return the note.
531
+ ```
532
+
533
+ Input modes are `request` (also omitted/undefined), additive `grant` with readonly
534
+ `paths`, and `unrestricted`. An empty grant adds nothing; ordinary request-authorized
535
+ fields still write. `unrestricted` admits eligible persisted roots, not arbitrary
536
+ keys on a `strict: false` document. It grants no rows, output, query/sort fields,
537
+ route guards, related operations or `$$sq` targets, and never stamps an upsert scope
538
+ into inserted data. `args.select` affects output only.
539
+
540
+ `InputFieldPath<TModel>` and `InputFieldAuthorization<TModel>` use bounded persisted
541
+ model paths without the virtual generic. Runtime validation also checks JS/dynamic
542
+ policies against the actual schema, embedded schemas and virtual registry. Use
543
+ `contacts.email`, not indices, brackets, `sub`, signed tokens or wildcards. Safe
544
+ Mixed/Map descendants are supported; reference interiors belong to another model.
545
+ Unknown paths, virtuals (including Mongoose aliases), `_id`, version keys,
546
+ permission metadata (including configured dotted paths), prototype/Mongoose keys
547
+ and execution/prepared-policy metadata cannot be elevated. Business fields named
548
+ `authorization` and `overrides` remain payload data.
549
+
550
+ Request-input compatibility also excludes a scope's registered virtual names through
551
+ Mixed/plain nesting: a root `receipt` virtual excludes submitted `bag.receipt`, while
552
+ entering a registered child scope uses that child's virtual names. This applies to
553
+ request/granted/unrestricted input and prepared JS/Map containers. Explicit grants
554
+ to these aliases reject; descendant patches leave unrelated existing siblings intact.
555
+
556
+ | Input boundary | Persistence behavior |
557
+ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
558
+ | Leaf (`profile.email`) | Replaces only the supplied leaf, including null/undefined. Omission does nothing; denied siblings survive. Necessary object ancestors may be initialized, but an existing scalar ancestor is not replaced. |
559
+ | Object container (`profile`) | Replaces its eligible subtree; omitted descendants are removed. Empty/null replacement is allowed. |
560
+ | Array container (`contacts`, or an authorized ancestor) | Replaces the array, including order, length, null/empty values and omitted child fields. Elevated replacements omit supplied embedded IDs; Mongoose generates IDs unless ordinary request authority already admits them. |
561
+ | Array descendant (`contacts.email`) | Patches existing object elements **by index**, never by supplied ID. Short inputs preserve trailing elements; excess elements, holes, null/scalar slots and scalar/invalid ancestors make no structural change. The same rule applies to nested/Mixed arrays. |
562
+
563
+ A descendant grant cannot create an absent array, **including on create or the
564
+ upsert-create branch**. Grant the array/ancestor container, or use existing request
565
+ container permission, to create/resize it. A narrower grant never narrows ordinary
566
+ request authority: an already request-authorized container retains replacement
567
+ semantics. Nested client JSON is required; literal dotted update keys are not updates.
568
+
569
+ Validation sees admitted data; prepare, transform, Mongoose casting/validation/save,
570
+ afterPersist and change hooks remain active. Trusted prepare additions and explicit
571
+ transform setters keep their business-write contract. Input/prepare values are
572
+ isolated; virtuals and control metadata are excluded even in permissive whole
573
+ containers and prepared JS values. Trusted prepare additions are business writes,
574
+ not a second input-policy grant.
575
+ JS class/hydrated containers are normalized to their data before admission; native
576
+ Date/Buffer/BSON leaves retain their types. Functions/symbols are not persisted input.
577
+ Mutation payloads, including class/Map contents and sparse arrays, obey
578
+ `requestComplexity` before parsing and input hooks; cycles return BadRequest. Input
579
+ admission and prepared-container sanitization also have hard structural ceilings of
580
+ 100 levels and 20,000 nodes. Grants do not bypass these limits. Unmodified persisted
581
+ virtual values outside the write boundary are preserved; they remain excluded from
582
+ ordinary output.
583
+ Pre-write update grants resolve once from the full hydrated persisted document, with input
584
+ structure (including schema-hidden roots and embedded fields) fetched privately. They are passed through
585
+ `context.docPermissions`, not stored on the document or reconstructed from input
586
+ `_permissions`. Create keeps request-only pre-document rules and resolves its document
587
+ hook after persistence. Post-write output gets fresh document grants; input authority
588
+ does not become an output handle or forge `_view`/`_edit` permissions.
589
+
590
+ **Migration:** a write formerly requiring trusted preparation can instead grant the
591
+ specific input path explicitly. An output grant alone still cannot write. Keep read
592
+ policies typed as `ServiceAuthorization`; use `MutationAuthorization` for updates/
593
+ upserts and `CreateAuthorization` for creates. Derive `{ outputFields }` for output
594
+ helpers rather than forwarding a mutation policy. Dedicated subdocument authorization
595
+ uses the scoped API below; model-root input policies are not forwarded into it.
596
+
597
+ ### Dedicated subdocument authorization
598
+
599
+ Internal `listSub`, `readSub`, `createSub`, `updateSub`, `bulkUpdateSub` and `deleteSub`
600
+ accept a final options object with **`authorization: { parent, child, visibility }`**.
601
+ Use `router.getService(req)` / `req.macl.getService<Model>(name)`. The public service,
602
+ root batches, generated basic/advanced routes, nested descriptors, custom-validator
603
+ envelopes and defaults reject these capabilities. Business payload properties named
604
+ `authorization` or `overrides` keep their ordinary meaning.
605
+
606
+ The named subdocument must be a **top-level embedded document array**, validated
607
+ against Mongoose (not a reference array, object, dotted path or virtual). Known-model
608
+ literal names infer the child and virtual shapes; unknown models keep runtime checks.
609
+ `SubdocumentModel<Parent, 'contacts'>` extracts the child type. Child field/row paths
610
+ are **relative to that child** (`email`, `preferences.theme`), while parent row paths
611
+ use the owning model (`tenantId`). Policies have no recursive `subdocuments` tree.
612
+
613
+ <!-- doc-example: partial -->
614
+
615
+ ```ts
616
+ import type { SubdocumentMutationAuthorization } from '@web-ts-toolkit/access-router';
617
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
618
+ type Order = { tenantId: string; contacts: { email: string; ssn: string }[] };
619
+ declare const req: ModelRequest;
620
+ declare const orderId: string;
621
+ declare const contactId: string;
622
+ declare const tenantId: string; // trusted server value
623
+ const parent = {
624
+ operation: { mode: 'grant' },
625
+ rows: { mode: 'scope', filter: { tenantId } },
626
+ } as const;
627
+ const authorization = {
628
+ parent, // only the owning parent's update gate/rows
629
+ child: {
630
+ operation: { mode: 'grant' }, // only contacts.update
631
+ inputFields: { mode: 'grant', paths: ['email'] },
632
+ // child update rows still use the actor's request policy
633
+ },
634
+ visibility: {
635
+ parent, // explicitly authorize the separate parent READ stage
636
+ child: {
637
+ operation: { mode: 'grant' }, // separate contacts.read gate
638
+ outputFields: { mode: 'grant', paths: ['email'] },
639
+ },
640
+ },
641
+ } as const satisfies SubdocumentMutationAuthorization<Order, Order['contacts'][number]>;
642
+ const result = await req.macl
643
+ .getService<Order>('Order')
644
+ .updateSub(orderId, 'contacts', contactId, { email: 'corrected@example.test' }, { authorization });
645
+ // Omit visibility to retain request-only post-write read gates/rows/fields.
646
+ // A successful hidden write returns data: null; do not retry merely for null output.
647
+ ```
648
+
649
+ | Internal method | Parent gate/rows | Child gate/rows and fields | Response |
650
+ | ----------------------------------------------------------------- | ---------------- | ---------------------------------------- | ------------------------------------------------------------- |
651
+ | `listSub(id, sub, { filter?, select?, authorization? })` | read | list; `outputFields` | Filtered list, visible count |
652
+ | `readSub(id, sub, subId, { select?, populate?, authorization? })` | read | read; `outputFields` | One selected child |
653
+ | `createSub(id, sub, data, { addFirst?, authorization? })` | update | create; `inputFields`; **no child rows** | Visible full stored array, including existing rows |
654
+ | `updateSub(id, sub, subId, data, { authorization? })` | update | update; `inputFields` | Visible updated child or successful `null` |
655
+ | `bulkUpdateSub(id, sub, data, { authorization? })` | update | update; `inputFields` | Visible targeted children in stored order, or successful `[]` |
656
+ | `deleteSub(id, sub, subId, { authorization? })` | update | delete; no fields | Removed ID; no visibility policy |
657
+
658
+ Supplying `authorization` (even `{}`) opts a trusted internal call into the two
659
+ listed **base-operation** gates. An omitted dimension uses request authority;
660
+ `operation: { mode: 'grant' }` permits exactly its gate. Calls with no supplied
661
+ policy retain their historical route-owned initiating guards. The generated
662
+ basic/advanced guards continue to choose their own request-only route entry.
663
+ Parent and child operation grants imply neither each other nor row/input/output
664
+ grants. Parent fields are inapplicable; they reject even in request mode.
665
+
666
+ Each rows dimension supports request/scope/unrestricted. `overrideFilter` still
667
+ runs once at its access stage; the resolved parent identifier, selected child
668
+ ID(s), owner scope and owning-array membership cannot be erased by rewriting,
669
+ including in-place mutation. False query/identifier/scope/transform is terminal.
670
+ Scope predicates are inert (no `$$sq`/`$$date` expansion), validate against their
671
+ own schema, and obey request complexity depth/node/logical-clause/`$in` budgets.
672
+ Grant arrays obey those budgets too. Invalid paths, unsupported dimensions and
673
+ malformed visibility policies reject **before any write**.
674
+
675
+ Mutation visibility is separate: `visibility.parent` supplies parent **read**
676
+ operation/rows; `visibility.child` supplies child **read** operation/rows and
677
+ `outputFields`. **Create also requires child list** operation/rows, independently
678
+ configured with `visibility.list`. Update/bulk do not require list and reject that
679
+ dimension. Visibility inherits no mutation authority; absent dimensions use request
680
+ policy. Successful hidden create/bulk responses are `data: [], count: 0`, and
681
+ successful hidden single updates are `data: null`. Counts measure returned rows.
682
+
683
+ Child input uses the input rules above: descendant grants preserve denied siblings
684
+ and patch existing nested array objects by index; explicit nested-container grants
685
+ replace their eligible subtree. Only create/delete change the **outer** array's
686
+ membership. Virtuals/control metadata/identity cannot be elevated. Dedicated input
687
+ also retains the virtual-name scrub within Mixed/plain nesting; grants matching
688
+ those aliases reject before effects. Update document grants resolve once from the
689
+ original hydrated **owning parent**, including hidden
690
+ persisted inputs; create retains request-only pre-document admission. Output
691
+ resolves a fresh raw-parent read-stage result, independently of serialized metadata.
692
+ Virtual access is list/read/create/update respectively; output fields use list for
693
+ list and read otherwise, with document grants using read. Shared finalization
694
+ remains active. Dedicated operations retain Mongoose casting/validation/save hooks;
695
+ they do not invoke model-root validate/prepare/transform/afterPersist chains on a child.
696
+
697
+ `readSub` populate targets always use their **own request authority**. Child output
698
+ grants admit the relationship only; they never elevate target fields, rows or gates.
699
+ Dedicated parent/child/visibility policies reject `related`. For embedded output
700
+ inside a model-root read, use its existing `related` target policy contract.
701
+
702
+ **Types/migration:** root and `/advanced` export `SubdocumentReadAuthorization`,
703
+ `SubdocumentMutationAuthorization`, `SubdocumentCreateAuthorization`,
704
+ `SubdocumentDeleteAuthorization`, `SubdocumentVisibilityAuthorization` and
705
+ `SubdocumentCreateVisibilityAuthorization`. Their generics are `<Parent, Child,
706
+ ChildVirtuals>` (delete has no virtual generic). `Subdocument*Options` are now
707
+ internal; use `PublicSubdocumentListOptions`, `PublicSubdocumentReadOptions`,
708
+ `PublicSubdocumentCreateOptions` and the empty `PublicSubdocumentMutationOptions`
709
+ for public adapters. Keep policies as server constants, not spreads of request
710
+ options. Positional helper overloads are removed; see the named-helper migration
711
+ and request-only data compatibility contract below.
712
+
713
+ ### Runnable authorization and handle reuse
714
+
715
+ This complete example uses an installed package, Express and MongoDB. Save it as
716
+ `authorization-example.mts` and run `node authorization-example.mts` on Node 22.18+
717
+ (or compile it with TypeScript/NodeNext first). `MONGODB_URL` defaults to local
718
+ MongoDB. It creates and removes its own uniquely named example database, serves one
719
+ loopback test request, and exits after its assertions pass. The route's policy and
720
+ example actor (`alice`) are server-owned constants, never HTTP inputs.
721
+
722
+ <!-- doc-example: complete-runtime -->
723
+
724
+ ```ts
725
+ import assert from 'node:assert/strict';
726
+ import { randomUUID } from 'node:crypto';
727
+ import { once } from 'node:events';
728
+ import type { Server } from 'node:http';
729
+ import express from 'express';
730
+ import mongoose from 'mongoose';
731
+ import {
732
+ createAccessRuntime,
733
+ type OutputAuthorization,
734
+ type ServiceAuthorization,
735
+ type MutationAuthorization,
736
+ type SubdocumentMutationAuthorization,
737
+ } from '@web-ts-toolkit/access-router';
738
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
739
+
740
+ type Order = {
741
+ status?: string;
742
+ internalNote?: string;
743
+ ownerId?: string;
744
+ tenantId: string;
745
+ contacts?: { email?: string; ssn?: string }[];
746
+ customer?: mongoose.Types.ObjectId;
747
+ };
748
+ type Virtuals = { summary: string };
749
+
750
+ async function runServiceAuthorizationExample() {
751
+ const connection = await mongoose
752
+ .createConnection(process.env.MONGODB_URL ?? 'mongodb://127.0.0.1:27017', {
753
+ dbName: `authorization_example_${randomUUID().replaceAll('-', '')}`,
754
+ })
755
+ .asPromise();
756
+ let server: Server | undefined;
757
+ try {
758
+ const api = createAccessRuntime();
759
+ api.setGlobalOptions({ globalPermissions: () => [] }); // no user grant for the note
760
+ const Customer = connection.model(
761
+ 'ExampleCustomer',
762
+ new mongoose.Schema({
763
+ name: String,
764
+ billingEmail: String,
765
+ tenantId: String,
766
+ }),
767
+ );
768
+ const OrderModel = connection.model<Order>(
769
+ 'ExampleOrder',
770
+ new mongoose.Schema<Order>({
771
+ status: String,
772
+ internalNote: { type: String, select: false },
773
+ ownerId: String,
774
+ tenantId: String,
775
+ contacts: [{ email: String, ssn: String }],
776
+ customer: { type: mongoose.Schema.Types.ObjectId, ref: Customer.modelName },
777
+ }),
778
+ );
779
+ const customerRouter = api.createRouter(Customer, {
780
+ operationAccess: { read: false },
781
+ baseFilter: { read: () => false },
782
+ permissionSchema: { name: true, billingEmail: false },
783
+ stripPermissionsField: true,
784
+ });
785
+ let documentHookCalls = 0;
786
+ let queryTransforms = 0;
787
+ const router = api.createRouter<Order, Virtuals>(OrderModel, {
788
+ basePath: '/orders',
789
+ operationAccess: { read: true },
790
+ stripPermissionsField: true,
791
+ baseFilter: { read: () => ({ tenantId: 'request-tenant' }) },
792
+ overrideFilter: {
793
+ read: (filter) => {
794
+ queryTransforms++;
795
+ return filter;
796
+ },
797
+ },
798
+ alwaysSelectFields: { read: ['ownerId'] }, // private projected document-hook input
799
+ docPermissions: {
800
+ read: (doc) => {
801
+ documentHookCalls++;
802
+ return { owner: doc !== null && typeof doc === 'object' && 'ownerId' in doc && doc.ownerId === 'alice' };
803
+ },
804
+ },
805
+ permissionSchema: { status: { read: 'owner' }, internalNote: false, summary: false },
806
+ virtuals: {
807
+ summary: {
808
+ dependsOn: ['status'],
809
+ read: (doc) => (doc.status === undefined ? undefined : `Status: ${doc.status}`),
810
+ },
811
+ },
812
+ });
813
+ const customer = await Customer.create({ name: 'Ada', billingEmail: 'billing@example.test', tenantId: 'backend' });
814
+ const rows = await OrderModel.insertMany([
815
+ {
816
+ status: 'ready',
817
+ internalNote: 'server note',
818
+ ownerId: 'alice',
819
+ tenantId: 'backend',
820
+ customer: customer._id,
821
+ contacts: [{ email: 'alice@example.test', ssn: 'hidden' }],
822
+ },
823
+ { status: 'closed', internalNote: 'other note', ownerId: 'bob', tenantId: 'backend' },
824
+ { status: 'draft', ownerId: 'alice', tenantId: 'backend' }, // note is absent
825
+ ]);
826
+ const output = {
827
+ outputFields: { mode: 'grant', paths: ['internalNote'] },
828
+ } as const satisfies OutputAuthorization<Order>;
829
+ const authorization = {
830
+ ...output,
831
+ rows: { mode: 'scope', filter: { tenantId: 'backend' } },
832
+ } as const satisfies ServiceAuthorization<Order, Virtuals>;
833
+ const quiet = { skim: true, lean: true, includePermissions: false, includeFieldPermissions: false };
834
+ const selected = { select: ['status', 'internalNote', '-_id'] };
835
+
836
+ async function verifyServiceAuthorization(req: ModelRequest) {
837
+ const service = router.getService(req); // alternatively req.macl.getService<Order, Virtuals>(OrderModel.modelName)
838
+ const result = await service.findOne({ _id: rows[0]._id }, selected, { ...quiet, authorization });
839
+ assert.ok(result.success);
840
+ assert.deepEqual(result.data, { status: 'ready', internalNote: 'server note' });
841
+ // ownerId and _permissions are absent, yet this handle retains both decisions.
842
+ const reuse = { documentAuthorization: result.context.documentAuthorization };
843
+ const picked = await service.pickAllowedFields(result.data, 'read', reuse);
844
+ assert.deepEqual(await service.trimOutputFields(picked, 'read', reuse), result.data);
845
+ assert.equal(documentHookCalls, 1);
846
+ const fresh = await service.pickAllowedFields(result.data, 'read', { authorization: output });
847
+ assert.deepEqual(fresh, { internalNote: 'server note' }); // no original owner decision
848
+ assert.equal(documentHookCalls, 1); // never rerun the hook against this trimmed DTO
849
+
850
+ const deniedStatus = await service.findOne({ _id: rows[1]._id }, selected, { ...quiet, authorization });
851
+ assert.ok(deniedStatus.success);
852
+ assert.deepEqual(deniedStatus.data, { internalNote: 'other note' });
853
+ const unselected = await service.findOne(
854
+ { _id: rows[0]._id },
855
+ { select: ['status', '-_id'] },
856
+ { ...quiet, authorization },
857
+ );
858
+ assert.ok(unselected.success);
859
+ assert.deepEqual(unselected.data, { status: 'ready' }); // grant does not select the note
860
+ const absent = await service.findOne({ _id: rows[2]._id }, selected, { ...quiet, authorization });
861
+ assert.ok(absent.success);
862
+ assert.deepEqual(absent.data, { status: 'draft' }); // grant does not create a value
863
+ const requestRows = await service.findOne({ _id: rows[0]._id }, selected, { ...quiet, authorization: output });
864
+ assert.equal(requestRows.success, false); // output grant cannot replace request row scope
865
+ if (!requestRows.success) assert.equal(requestRows.code, 'not_found');
866
+
867
+ router.setOption('permissionSchema', { status: { read: 'owner' } }); // note rule now missing
868
+ const missingRule = await service.findOne({ _id: rows[0]._id }, selected, { ...quiet, authorization });
869
+ assert.ok(missingRule.success);
870
+ assert.equal(missingRule.data.internalNote, 'server note');
871
+ const before = queryTransforms;
872
+ await service.findOne({ _id: rows[0]._id }, selected, {
873
+ ...quiet,
874
+ authorization: { ...output, rows: { mode: 'unrestricted' } },
875
+ });
876
+ assert.equal(queryTransforms, before + 1); // unrestricted still runs overrideFilter
877
+
878
+ const related = await service.findOne(
879
+ { _id: rows[0]._id },
880
+ {
881
+ select: ['summary', 'contacts.email', 'customer', '-_id'],
882
+ populate: [{ path: 'customer', select: ['name', 'billingEmail'] }],
883
+ },
884
+ {
885
+ ...quiet,
886
+ authorization: {
887
+ rows: authorization.rows,
888
+ outputFields: { mode: 'grant', paths: ['internalNote', 'summary', 'contacts.email', 'customer'] },
889
+ related: {
890
+ customer: {
891
+ model: Customer.modelName,
892
+ operation: { mode: 'grant' },
893
+ rows: { mode: 'scope', filter: { tenantId: 'backend' } },
894
+ outputFields: { mode: 'grant', paths: ['billingEmail'] },
895
+ },
896
+ },
897
+ },
898
+ },
899
+ );
900
+ assert.ok(related.success);
901
+ assert.equal(Reflect.get(related.data, 'summary'), 'Status: ready');
902
+ assert.equal(related.data.internalNote, undefined); // granted, still unselected
903
+ assert.equal(related.data.contacts?.[0].email, 'alice@example.test');
904
+ assert.equal(related.data.contacts?.[0].ssn, undefined);
905
+ const child: unknown = Reflect.get(related.data, 'customer');
906
+ assert.ok(child && typeof child === 'object');
907
+ const childHandle = related.context.getDocumentAuthorization(child);
908
+ assert.ok(childHandle);
909
+ const childPick = await customerRouter
910
+ .getService(req)
911
+ .pickAllowedFields(child, 'read', { documentAuthorization: childHandle });
912
+ assert.equal(Reflect.get(childPick, 'billingEmail'), 'billing@example.test');
913
+ assert.equal(Reflect.get(childPick, 'tenantId'), undefined);
914
+
915
+ // Input authority is independent: patch the existing first contact, preserve ssn,
916
+ // persist the denied note, and grant neither field in the mutation response.
917
+ const input = {
918
+ inputFields: { mode: 'grant', paths: ['internalNote', 'contacts.email'] },
919
+ } as const satisfies MutationAuthorization<Order, Virtuals>;
920
+ const updated = await service.updateOne(
921
+ { _id: rows[0]._id },
922
+ {
923
+ internalNote: 'backend write',
924
+ contacts: [{ email: 'corrected@example.test' }],
925
+ },
926
+ { select: ['internalNote', 'contacts.email', '-_id'] },
927
+ { ...quiet, authorization: input },
928
+ );
929
+ assert.ok(updated.success);
930
+ assert.equal(updated.data.internalNote, undefined);
931
+ const persisted = await OrderModel.collection.findOne({ _id: rows[0]._id });
932
+ assert.equal(persisted?.internalNote, 'backend write');
933
+ assert.equal(persisted?.contacts?.[0].email, 'corrected@example.test');
934
+ assert.equal(persisted?.contacts?.[0].ssn, 'hidden');
935
+
936
+ // A dedicated child write has independent parent/child gates and read visibility.
937
+ const persistedContact = persisted?.contacts?.[0];
938
+ assert.ok(persistedContact);
939
+ const contactId = String(Reflect.get(persistedContact, '_id'));
940
+ const childPolicy = {
941
+ parent: { operation: { mode: 'grant' } }, // parent update gate only
942
+ child: { operation: { mode: 'grant' }, inputFields: { mode: 'grant', paths: ['email'] } },
943
+ } as const satisfies SubdocumentMutationAuthorization<Order, NonNullable<Order['contacts']>[number]>;
944
+ const hiddenChild = await service.updateSub(
945
+ String(rows[0]._id),
946
+ 'contacts',
947
+ contactId,
948
+ { email: 'dedicated@example.test' },
949
+ { authorization: childPolicy },
950
+ );
951
+ assert.ok(hiddenChild.success);
952
+ assert.equal(hiddenChild.data, null); // ordinary parent read rows still deny this tenant
953
+ const visibleChild = await service.readSub(String(rows[0]._id), 'contacts', contactId, {
954
+ select: ['email'],
955
+ authorization: {
956
+ parent: { rows: { mode: 'scope', filter: { tenantId: 'backend' } } },
957
+ child: { operation: { mode: 'grant' }, outputFields: { mode: 'grant', paths: ['email'] } },
958
+ },
959
+ });
960
+ assert.ok(visibleChild.success);
961
+ assert.ok(visibleChild.data && typeof visibleChild.data === 'object');
962
+ assert.equal(Reflect.get(visibleChild.data, 'email'), 'dedicated@example.test');
963
+ const childStored = await OrderModel.collection.findOne({ _id: rows[0]._id });
964
+ assert.equal(childStored?.contacts?.[0].email, 'dedicated@example.test');
965
+ assert.equal(childStored?.contacts?.[0].ssn, 'hidden');
966
+ }
967
+
968
+ router.router.post('/verify', async (req, res) => {
969
+ await verifyServiceAuthorization(req as ModelRequest); // router middleware has initialized req.macl
970
+ res.json({ ok: true });
971
+ });
972
+ const app = express();
973
+ app.use(express.json());
974
+ app.use(router.routes);
975
+ server = app.listen(0, '127.0.0.1');
976
+ await once(server, 'listening');
977
+ const address = server.address();
978
+ assert.ok(address && typeof address !== 'string');
979
+ const response = await fetch(`http://127.0.0.1:${address.port}/orders/verify`, { method: 'POST' });
980
+ assert.equal(response.status, 200, await response.text());
981
+ } finally {
982
+ if (server) await new Promise<void>((resolve, reject) => server!.close((err) => (err ? reject(err) : resolve())));
983
+ await connection.dropDatabase(); // only the unique example database created above
984
+ await connection.close();
985
+ }
986
+ }
987
+
988
+ void runServiceAuthorizationExample().catch((error) => {
989
+ console.error(error);
990
+ process.exitCode = 1;
95
991
  });
96
992
  ```
97
993
 
994
+ The target policy explicitly supplies **operation, row and output** authority. A
995
+ parent `customer` grant only admits that relationship; ref IDs and requested match/
996
+ join predicates still constrain target rows. A virtual grant permits selected getter
997
+ evaluation with its private dependencies, not database filtering/sorting or writes.
998
+ Getter failure/undefined and missing dependencies can still omit its output.
999
+
1000
+ ### Backend row authorization and filter migration
1001
+
1002
+ Internal `find`, `findOne`, `findById`, `updateOne`, `updateById` and `upsert`
1003
+ accept a per-call `options.authorization.rows` policy:
1004
+
1005
+ <!-- doc-example: partial -->
1006
+
1007
+ ```ts
1008
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
1009
+ declare const req: ModelRequest;
1010
+ const service = req.macl.getService<{ status: string; tenantId: string }>('Order');
1011
+ declare const tenantId: string;
1012
+ const result = await service.find(
1013
+ { status: 'open' },
1014
+ { select: ['status'], limit: 20 },
1015
+ {
1016
+ includeCount: true,
1017
+ authorization: { rows: { mode: 'scope', filter: { tenantId } } },
1018
+ },
1019
+ );
1020
+ ```
1021
+
1022
+ | Row mode | Effective predicate |
1023
+ | ------------------- | ----------------------------------------------------------------------- |
1024
+ | `request` (default) | Configured query transform AND request `baseFilter` |
1025
+ | `scope` | Same query transform AND supplied trusted scope, replacing `baseFilter` |
1026
+ | `unrestricted` | Same query transform, without request `baseFilter` |
1027
+
1028
+ `overrideFilter.<access>` always transforms the ordinary predicate **once**. It
1029
+ can intentionally rewrite query input, and its restrictions/false denial remain
1030
+ effective in every row mode. Mandatory reference/join constraints and trusted
1031
+ scope are ANDed afterward. A trusted scope is an inert database predicate:
1032
+ `$$sq`/`$$date` values in it are never expanded. `{}` explicitly allows all rows
1033
+ within the transformed query; null/undefined/malformed scope filters reject.
1034
+
1035
+ False query, identifier, or trusted scope is terminal before parser/identifier
1036
+ work as applicable. Transform/base-filter false is terminal before primary
1037
+ persistence or subsequent populate/include dispatch, but a read/list transform
1038
+ receives the parsed query: necessary template subqueries may already have run.
1039
+ Mutation lookup predicates retain their existing inert, non-client-parsing
1040
+ behavior; mutation **data** still follows normal parsing and write admission.
1041
+ An allowed upsert miss may create; lookup scope does not stamp values into input.
1042
+
1043
+ Private prepared execution reuses the authorized predicate for upsert's update
1044
+ branch, include reads/lists/counts and `find.totalCount`. Correlated read/list
1045
+ templates parse before parent substitution; substituted marker-shaped parent data
1046
+ stays inert. Correlated count templates retain their non-parsing behavior. These
1047
+ tokens are identity-bound internal objects, never arguments, JSON options or DTO
1048
+ properties. Standalone `exists`, `count`, `delete` and `distinct` retain their
1049
+ request-policy signatures; sort/distinct permission checks remain independent
1050
+ of output grants. Original request/user permissions remain intact across calls.
1051
+
1052
+ **Breaking-release migration:** old object `overrides.filter` bypassed both
1053
+ filter hooks. Replace it with an ordinary predicate plus explicit row policy.
1054
+ Move replaceable user row restrictions from `overrideFilter` into `baseFilter`;
1055
+ there is no query-transform bypass option. Use `findOne`/`updateOne` for custom
1056
+ lookup predicates instead of `overrides.idFilter`. Release guidance is maintained
1057
+ here and in the task record.
1058
+
1059
+ ### Distinct: whole-value request authorization
1060
+
1061
+ `GET /distinct/:field`, `POST /distinct/:field`, root `op: 'distinct'`, and trusted
1062
+ `service.distinct(field, { filter })` share the same pre-query field admission.
1063
+ Distinct returns Mongo's **raw complete values**; it does not trim nested fields or
1064
+ re-deduplicate modified objects. This preserves distinct equivalence and the returned
1065
+ `count`. An unsafe field returns `forbidden` (HTTP/root-entry **403**) before any
1066
+ distinct database call, even when the supplied filter would currently match no rows.
1067
+
1068
+ - The requested persisted path needs an explicit request `read` rule, either a flat
1069
+ dotted key or a nested `.sub` entry. A child grant does not authorize its parent.
1070
+ Nested entries use ordinary ancestor gates and exact dotted-rule precedence.
1071
+ Scalar `_id` retains its implicit identity access.
1072
+ - Declared scalar values (including native BSON scalar types), scalar reference IDs,
1073
+ and scalar arrays are eligible. Closed nested objects, single-nested documents and
1074
+ embedded document arrays require request-proven access to **every** declared returned
1075
+ descendant. Missing or denied child rules reject the entire distinct operation.
1076
+ - Mixed values/descendants, arrays containing Mixed, whole Maps, unknown types,
1077
+ recursive object schemas, discriminator-dependent composites and non-strict composites
1078
+ cannot prove a complete safe value and reject, including when nested inside an otherwise
1079
+ allowed container. A shared discriminator scalar path must have a scalar type in every
1080
+ registered variant. An explicitly granted named Map key may qualify when its schema value
1081
+ is a scalar or a fully allowed closed object. Admission never infers a type from the
1082
+ current stored values.
1083
+ - Named nested policy restrictions are checked even when that path is absent from the
1084
+ current Mongoose schema: stale/unknown policy branches do not authorize raw stored keys.
1085
+ - Permission metadata paths and their containers, protected stored descendants, and
1086
+ registered virtual paths and their containers reject. Distinct never runs getters.
1087
+ - Boolean grants and permission expressions already satisfied by the original request
1088
+ can authorize disclosure. Document-dependent grants and **all field-rule callbacks**
1089
+ reject: executing a callback with fabricated empty document grants cannot prove that
1090
+ the returned values are readable. Neither `docPermissions`, backend `outputFields`
1091
+ grants nor a prior document handle supplies distinct authority. Evaluation failures
1092
+ also deny; they do not retry through a more permissive field allowlist.
1093
+
1094
+ For example, this `permissionSchema` permits `distinct('profile.public')` but rejects
1095
+ `distinct('profile')` and `distinct('profile.secret')`:
1096
+
1097
+ ```json
1098
+ { "profile": { "read": true, "sub": { "public": true, "secret": false } } }
1099
+ ```
1100
+
1101
+ **Breaking-release migration:** request-only field callbacks previously ran with empty
1102
+ document grants. Move those decisions into `globalPermissions` and use a declarative
1103
+ permission key in `permissionSchema`. Query a permitted scalar child when a composite
1104
+ contains private fields. The generated operation guards, request `read` row filters,
1105
+ filter validation/casting and virtual-predicate exclusion still apply; distinct accepts
1106
+ no backend authorization option.
1107
+
1108
+ ### Breaking release: service overrides migration
1109
+
1110
+ Explicit authorization and complete service-container removal ship together.
1111
+ `overrides` is absent from internal service argument/default types and is rejected
1112
+ at service entry with an actionable `bad_request` ErrorResult **before parsing,
1113
+ identifier generation, child dispatch or persistence**. This includes `{}`, `null`,
1114
+ `undefined`, inherited/non-enumerable JS slots, and mixtures with valid new
1115
+ authorization. Delete the old property entirely; setting it to `undefined` is not
1116
+ a migration. Moving it from args to options also rejects. There is no deprecated
1117
+ adapter or alternate legacy authority path.
1118
+
1119
+ | Removed usage (before) | Replacement (after) |
1120
+ | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1121
+ | `overrides.select: ['internalNote']` | `findOne(query, { select: ['status', 'internalNote'] }, { authorization: { outputFields: { mode: 'grant', paths: ['internalNote'] } } })`. Selection describes output; the grant supplies additive authority. |
1122
+ | `overrides.filter: trustedPredicate` for row authority | Pass the desired ordinary query, then `authorization: { rows: { mode: 'scope', filter: trustedScope } }` or `rows: { mode: 'unrestricted' }`. The configured query transform still runs once. |
1123
+ | `overrides.filter: preparedPredicate` for package query reuse | Package-owned upsert/includes/counts now use authenticated private prepared-query execution. Backend consumers pass ordinary predicates to service methods; no prepared/skip flag is exposed. |
1124
+ | `overrides.populate: [{ path: 'customer', select: ['name', 'internalNote'] }]` | Ordinary `populate` plus a parent `outputFields` grant for `customer` when needed and `related.customer: { model: 'Customer', operation: { mode: 'grant' }, outputFields: { mode: 'grant', paths: ['internalNote'] } }` for the independently needed target capabilities. |
1125
+ | `overrides.idFilter: { slug }` | `findOne({ slug }, args, options)` / `updateOne({ slug }, data, args, options)` with explicit row policy when needed. By-ID calls always use normal identifier resolution. |
1126
+ | `overrides.effectiveSelect: ['status']` | `create(data, { select: ['status'] }, options)` / `updateOne(query, data, { select: ['status'] }, options)` / `upsert(query, data, { select: ['status'] }, options)`. Public `returningAll: false` computes this ordinary selection once. |
1127
+ | `overrides.operation: 'upsert'` | Call `upsert(query, data, args, options)`. Private execution retains initiating `upsert` while create/update branch accesses govern getters, output and document grants. |
1128
+ | `pickAllowedFields(doc, 'read', ['internalNote'])` using helper `baseFields` as visibility authority | `pickAllowedFields(doc, 'read', { authorization: { outputFields: { mode: 'grant', paths: ['internalNote'] } } })`, or reuse the original `documentAuthorization` handle for same-execution document decisions. |
1129
+ | `genSelect('read', select, true)` using helper `skipChecks` for provisional fetching | `genSelect('read', select, { authorization: { outputFields: { mode: 'request' } } })` / `genQuerySelect(...)`. Named planning defers document-dependent rules; use an explicit output grant when backend visibility is required. |
1130
+
1131
+ The helper overloads in the last two rows are also **removed in this next breaking
1132
+ release**, with the named replacements available now. JavaScript booleans, arrays,
1133
+ retired named bypass keys, and extra positional arguments reject with actionable
1134
+ BadRequest errors, including `undefined` followed by an extra argument. TypeScript
1135
+ rejects the removed signatures. There is no adapter or deprecation interval.
1136
+
1137
+ Old nullish filter/ID overrides meant normal generation: omit the container and
1138
+ omit `authorization.rows` (or use `{ mode: 'request' }`). Old terminal
1139
+ `overrides.filter: false` becomes an ordinary `false` query or
1140
+ `authorization.rows: { mode: 'scope', filter: false }`; the latter also prevents
1141
+ by-ID resolution and persistence-bearing client subqueries. A normal identifier
1142
+ hook returning false remains terminal in every row mode. `{ mode: 'scope', filter: {} }`
1143
+ is an explicit all-row scope within the transformed query; a supplied null/undefined
1144
+ scope filter is BadRequest, not request fallback. Output grants never revive row denial.
1145
+
1146
+ For example, migrate population through explicit source and target policies:
1147
+
1148
+ <!-- doc-example: partial -->
1149
+
1150
+ ```ts
1151
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
1152
+ declare const req: ModelRequest;
1153
+ declare const orderId: string;
1154
+ const service = req.macl.getService<{ status: string; customer: string }>('Order');
1155
+ const result = await service.findOne(
1156
+ { _id: orderId },
1157
+ { select: ['status', 'customer'], populate: [{ path: 'customer', select: ['name', 'internalNote'] }] },
1158
+ {
1159
+ authorization: {
1160
+ outputFields: { mode: 'grant', paths: ['customer'] },
1161
+ related: {
1162
+ customer: {
1163
+ model: 'Customer',
1164
+ operation: { mode: 'grant' },
1165
+ outputFields: { mode: 'grant', paths: ['internalNote'] },
1166
+ },
1167
+ },
1168
+ },
1169
+ },
1170
+ );
1171
+ ```
1172
+
1173
+ Target rows still use request scope here; explicitly scope them if needed. Parent
1174
+ authority never grants target fields/operations implicitly. Ordinary populate now
1175
+ uses the same target-policy contract for reads and post-create/update/upsert output;
1176
+ the old create behavior that ignored `overrides.populate` has been removed.
1177
+
1178
+ **Ordinary document-grant behavior also changes in this release:** previously a
1179
+ skim read with metadata disabled and no virtual candidates could skip the document
1180
+ hook, losing otherwise allowed persisted fields. Full internal document grants
1181
+ are now resolved once per projected document/access stage regardless of `skim`,
1182
+ `includePermissions`, `includeFieldPermissions`, stripping or exposed-key limits.
1183
+ Only serialized metadata changes with those settings. Configure required private
1184
+ hook inputs with `alwaysSelectFields`; same-execution handle picks retain the
1185
+ original decisions after those inputs and `_permissions` are removed. Temporary
1186
+ backend grants do not forge request-user `_view`/`_edit` or document-hook metadata.
1187
+
1188
+ Output grants cannot write protected fields. Explicit model mutation `inputFields`
1189
+ and dedicated parent/child/visibility policies are independent opt-in capabilities;
1190
+ validation and persistence hooks remain active. `new()` retains its ignored-select
1191
+ template behavior; standalone row-only methods retain request-policy signatures.
1192
+ Business payload/filter properties called `overrides` or `authorization` remain
1193
+ valid under ordinary rules.
1194
+
1195
+ ### Breaking release: named helpers and default behavior
1196
+
1197
+ Model `Base`/`Service`/`PublicService` expose these finite signatures; Core prepends
1198
+ `modelName`. The option types are exported from both the root and `/advanced`:
1199
+
1200
+ | Helper | Supported final argument / default |
1201
+ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
1202
+ | `genSelect(access, select?, options?)`, `genQuerySelect(...)` | `OutputPlanningOptions<TModel, TVirtuals>`, defaults to `{}` |
1203
+ | `pickAllowedFields(doc, access, options?)`, `trimOutputFields(...)` | `OutputFieldOptions<TModel, TVirtuals>`, defaults to `{}` |
1204
+ | `genAllowedFields(doc, access, options?)` | `AllowedOutputFieldOptions<TModel, TVirtuals>`, defaults to `{ purpose: 'output' }`; supplied objects require the purpose |
1205
+ | `genPopulate(access?, populate?, options?)` | Request-only `PopulatePlanningOptions`, defaults to `{}`; only `scopePath` is supported |
1206
+
1207
+ **Default model calls changed.** There is no hidden no-option legacy path:
1208
+
1209
+ - Planning defers document-dependent rules without running permission functions,
1210
+ excludes definitely denied fields, strips virtual names from DB projections and
1211
+ retains canonical private identity/dependency inputs. Omitted select uses ordinary
1212
+ request candidates, or only identity output when `requireExplicitSelect` is true.
1213
+ For compatibility, explicit null/empty select considers all candidates even with
1214
+ that setting, still subject to request rules or explicit policy. It is no grant.
1215
+ A fetch projection never proves response or population permission.
1216
+ - Pick/trim isolate hydrated or plain inputs rather than mutating persistence
1217
+ documents. Fresh raw input runs the document-permissions hook; serialized
1218
+ `_permissions` cannot grant fields. Nested/array output rules, private `_id`
1219
+ retention and configured metadata posture match named output behavior. On known
1220
+ trimmed output, use its handle to retain document-dependent decisions. Unsupported
1221
+ scalar/array documents reject. `genAllowedFields` only reports existing paths.
1222
+ - Request/input and query-field checks remain separate. Migrate writes to service
1223
+ mutations with independent `authorization.inputFields`; output picks never admit
1224
+ writes. Distinct/sort and request-user `_view`/`_edit` do not inherit output grants.
1225
+
1226
+ Scope migration uses **ordinary response segments**: replace
1227
+ `genSelect('read', select, false, ['contacts', 'sub'])` with
1228
+ `genSelect('read', select, { scopePath: ['contacts'] })` **for output planning**.
1229
+ Model output grants remain relative to the owning model (`contacts.email`).
1230
+ Replace `genPopulate('read', descriptors, ['contacts', 'sub'])` with
1231
+ `genPopulate('read', descriptors, { scopePath: ['contacts'] })`. This helper keeps
1232
+ source/target request checks; a privately fetched denied source still cannot populate.
1233
+ Use normal service `populate` plus scoped `authorization.related` for explicit
1234
+ backend target authority. The obsolete raw `buildVirtualPlan`/`buildSubVirtualPlan`
1235
+ service builders are removed; use named fetch helpers. Retained plans, private
1236
+ identity/metadata/join retention and document-result issuance belong to package execution.
1237
+
1238
+ #### Request-only data helpers
1239
+
1240
+ `DataCore` keeps `genSelect`, `genAllowedFields`, and `pickAllowedFields` with a
1241
+ leading `dataName`; `DataService` also provides the `genQuerySelect` and
1242
+ `trimOutputFields` aliases. Each accepts optional **`DataFieldOptions`** as its last
1243
+ argument, defaulting to `{}`. Its only supported setting is readonly
1244
+ `scopePath: ['contacts']`, selecting the `contacts.sub` permission-schema branch.
1245
+ Selection, input document and resulting field paths are relative to that scope.
1246
+ `DataFieldOptions` is exported from root and `/advanced`.
1247
+
1248
+ These helpers retain the **request-only data evaluator**: no Mongoose registration
1249
+ or catalog, model document hooks, virtual computation, or DataService authorization.
1250
+ Permission functions receive the original request as `this` and only its permissions
1251
+ argument. Allowed-fields returns a schema allowlist even with a null/empty document;
1252
+ pick/trim select existing values from it. An allowed container remains a whole value;
1253
+ use a scoped helper to filter a child. `_id` and metadata require ordinary data-field
1254
+ rules, with no automatic retention.
1255
+
1256
+ Data `genSelect` returns the intersection of normalized requested tokens and
1257
+ request-authorized schema fields. **Compatibility:** omitted/null/empty,
1258
+ exclusion-only, unknown-only and denied-only selections can produce `[]`.
1259
+ Data `find`/`findOne` interpret an empty intersection as **no additional projection
1260
+ after request trimming**, returning all request-authorized fields. A non-empty
1261
+ intersection restricts output to those admitted requested fields. Row filtering,
1262
+ sort checks, counts, page-only shaping and hook concurrency retain their existing rules.
1263
+
1264
+ Data positional `skipChecks` never elevated data fields; remove that argument.
1265
+ Old arbitrary `baseFields` elevation is **removed and unsupported**, with no new
1266
+ bypass switch or data-grant capability. Model output policies/handles, positional
1267
+ arrays/booleans, unknown options and extra arguments reject on data helpers too.
1268
+
1269
+ <!-- doc-example: partial -->
1270
+
1271
+ ```ts
1272
+ import type { DataFieldOptions } from '@web-ts-toolkit/access-router';
1273
+ import type { DataRequest } from '@web-ts-toolkit/access-router/advanced';
1274
+ declare const req: DataRequest;
1275
+ const service = req.dacl.getService('Directory');
1276
+ const options = { scopePath: ['contacts'] } as const satisfies DataFieldOptions;
1277
+ const select = await service.genQuerySelect('read', ['email'], options);
1278
+ const fields = await service.genAllowedFields(null, 'read', options);
1279
+ const contact = await service.pickAllowedFields({ email: 'a@example.test', private: 'hidden' }, 'read', options);
1280
+ // Results follow Directory.permissionSchema.contacts.sub request rules.
1281
+ console.log(select, fields, contact);
1282
+ ```
1283
+
1284
+ ## Data routers
1285
+
1286
+ For small reference datasets, create an in-memory list/read router. Its field
1287
+ policy is request-only; the [runnable data-router split](#runnable-data-router-split)
1288
+ also exercises basic/advanced guards and live setters without MongoDB.
1289
+
1290
+ <!-- doc-example: partial -->
1291
+
1292
+ ```ts
1293
+ import { createAccessRuntime } from '@web-ts-toolkit/access-router';
1294
+
1295
+ const api = createAccessRuntime();
1296
+ const fruitRouter = api.createDataRouter('fruit', {
1297
+ basePath: '/fruit',
1298
+ idParam: 'id',
1299
+ idField: 'id',
1300
+ operationAccess: { list: true, read: true },
1301
+ data: [{ id: 'apple', name: 'Apple', public: true }],
1302
+ permissionSchema: { id: true, name: true, public: true },
1303
+ });
1304
+ // Mount fruitRouter.routes on an Express app after express.json().
1305
+ ```
1306
+
98
1307
  In-memory data routers default `listHardLimit` to `1000`, matching model routers. A list request without
99
1308
  `limit`/`pageSize`, or with a malformed limit passed through a trusted service call, is capped at that finite
100
1309
  default unless the router sets `listHardLimit` explicitly. `meta.totalCount` still reflects the full authorized
101
- match set when counts are requested; only returned rows are trimmed and decorated. Per-row data trim/decorate
102
- hooks run with bounded concurrency controlled by `requestComplexity.maxHookConcurrency` (default `10`).
103
- Runtime options and data-router records are copied when configured, and option getter results are frozen
104
- snapshots. Mutating the original options object, a fetched options snapshot, or the original `data` array does
105
- not change live runtime policy or served in-memory records. Replace configured data through `router.data(next)`
106
- or `setDataOption(name, 'data', next)`.
1310
+ match set when counts are requested; only returned rows are trimmed and decorated. Data request-field
1311
+ selection/trimming and `decorate`/`decorateAll` share request/runtime hook admission with model output
1312
+ work, controlled by `requestComplexity.maxHookConcurrency` (default `10`). See
1313
+ [Hook admission and reentrant service calls](#hook-admission-and-reentrant-service-calls) for the counted
1314
+ chain and nested-call contract. Data has no model document-grant or virtual hook family.
1315
+
1316
+ **Basic data-list queries.** `GET /fruit?select=id&sort=-id&limit=10&include_count=true` returns the same
1317
+ selected rows and pagination metadata as `POST /fruit/__query` with this body:
1318
+
1319
+ ```json
1320
+ { "select": ["id"], "sort": "-id", "limit": 10, "options": { "includeCount": true } }
1321
+ ```
1322
+
1323
+ GET `select` accepts comma/whitespace-separated fields or repeated parameters. GET `sort` is one
1324
+ space-separated field string, with `-` for descending order. Sorting uses list field permissions,
1325
+ including permitted fields omitted from `select`; malformed or denied sort fields return HTTP 400.
1326
+ Filtering and sorting precede pagination, and `totalCount` counts the entire filtered set. Without
1327
+ selection or sorting, lists retain their list-allowed fields and configuration order. Data's existing
1328
+ empty-selection/intersection behavior still applies: no additional projection after request field trimming.
1329
+
1330
+ ### In-memory data ownership
1331
+
1332
+ Configuration owns a copy of `data`, including nested values in pre-frozen
1333
+ caller containers. Later changes to the original records cannot change served data. Replace data through
1334
+ `router.data(next)` or `runtime.setDataOption(name, 'data', next)`; `undefined` clears it. Nested setters such as
1335
+ `router.data('0.profile', nextProfile)` replace data copy-on-write and retain other rows. A service captures one
1336
+ data version at construction: replacing data leaves existing services/snapshots on their original version,
1337
+ while newly constructed services use the replacement.
1338
+
1339
+ Supported data is an **acyclic ordinary array** containing these values:
1340
+
1341
+ - Plain objects (including null-prototype objects), ordinary/sparse arrays, strings, booleans, numbers,
1342
+ bigint, null and undefined. Own enumerable string-keyed properties are copied; accessors are evaluated
1343
+ once during admission and stored as values. Non-enumerable plain properties are ignored.
1344
+ - Exact built-in `Date`, `Buffer`, `Map`, `Set`, `RegExp`, fixed non-shared `ArrayBuffer`, `DataView`, and
1345
+ typed arrays: `Int8Array`/`Int16Array`/`Int32Array`, `Uint8Array`/`Uint16Array`/`Uint32Array`,
1346
+ `Uint8ClampedArray`, `Float32Array`/`Float64Array`, `BigInt64Array`/`BigUint64Array`.
1347
+ Map keys/values and Set entries are recursively owned; Date timestamps and RegExp source/flags/numeric
1348
+ `lastIndex` survive. Binary views and Buffers copy their visible bytes into independent, exact-sized
1349
+ backing storage with offset zero. Sharing between distinct views/backing buffers is not retained.
1350
+
1351
+ Functions, symbol values/keys, cycles, proxies, arbitrary classes (including Mongoose documents/ObjectIds),
1352
+ native subclasses/custom native properties, foreign object/native prototypes, WeakMap/WeakSet/Promise and
1353
+ other unlisted instances are rejected inside `data`. Shared, resizable and detached binary storage also rejects.
1354
+ Admission throws a path-bearing `TypeError` before publishing any keys of that data-options update;
1355
+ an accessor that throws propagates its error and likewise leaves the update unapplied. Convert unsupported
1356
+ application objects into supported data first. This data-specific contract preserves the identities of
1357
+ hook functions, Mongoose models, validators and runtime handles in ordinary options.
1358
+
1359
+ Option getter results and `router.options` remain frozen snapshots. **Data snapshot plain containers are
1360
+ frozen; native-valued properties return fresh, detached mutable copies on each access.** This includes
1361
+ `runtime.getDataOptions(name).data`, `getDataSnapshot`, `getDataOption`/`getExactDataOption`, and bootstrap
1362
+ snapshots. Mutating a native copy or its Map/Set contents cannot alter that snapshot or later readers;
1363
+ native identity across property reads is not stable. Repeated ordinary option/snapshot reads reuse the
1364
+ safe frozen data view without traversing the whole dataset.
1365
+
1366
+ `DataService.find`, `findOne` and `findById` return independently mutable selected data, including nested
1367
+ objects/arrays and supported natives. Detachment happens **after field trimming, selection and pagination**,
1368
+ so only returned values are copied; different returned rows also remain independent when their configured
1369
+ inputs shared references. Direct/root response `decorate` and `decorateAll` hooks may mutate those nested
1370
+ outputs in place. DataService's request-only field rules and empty-selection semantics still apply.
1371
+ Native preservation is an in-process contract; HTTP uses normal Express JSON serialization. Use trusted
1372
+ decoration to choose wire representations for values such as Map, Set or bigint.
1373
+
1374
+ ## Denial and request validation
107
1375
 
108
1376
  ACL filter hooks may return `false` to deny a query. That denial is terminal through identifier, override, and
109
1377
  base-filter composition. `overrideFilter` is a trusted hook that may replace an ordinary client or identifier
@@ -112,12 +1380,11 @@ that denial. Returning `false` from `overrideFilter` also denies the query. Empt
112
1380
  normal unrestricted meaning unless a base filter restricts them.
113
1381
 
114
1382
  **Model service denial contract:** internal `findOne`/`find`/`updateOne`/`upsert`
115
- `args.overrides.filter` and `findById`/`updateById` `args.overrides.idFilter` distinguish
116
- `false` from omitted/`undefined`/`null`. False returns a `forbidden` ErrorResult without
117
- fallback filter/identifier generation or persistence (including client subqueries).
118
- Nullish overrides use normal generation. Trusted object filter overrides replace the
119
- generated filter; object **idFilter** overrides replace identifier resolution but still
120
- receive row policy. These service arguments are trusted application inputs.
1383
+ ordinary false queries, false identifier results and explicit false row scopes are
1384
+ terminal. Early known false returns a `forbidden` ErrorResult without fallback
1385
+ generation or persistence-bearing parsing. Omitted row authority uses request policy;
1386
+ scope/unrestricted replace only its base scope. The removed service `overrides`
1387
+ control instead returns `bad_request`, including when its old filter value is false.
121
1388
 
122
1389
  `Service.exists(filter, { access: 'read', includeId: false })` returns a successful
123
1390
  boolean for allowed queries; `includeId: true` returns an `{ _id }` record or `null`.
@@ -134,6 +1401,8 @@ Generated direct routes map Forbidden to HTTP 403; root batches use per-entry 40
134
1401
  HTTP 200. **Custom Express routes own HTTP serialization:** returning an ErrorResult or
135
1402
  calling `res.json(result)` does not automatically set HTTP 403; map its code explicitly.
136
1403
 
1404
+ ### Advanced mutation validation
1405
+
137
1406
  Advanced mutation validators drive dispatch: nested `data` validators run first, then the whole-body (`default`)
138
1407
  validator sees that transformed envelope, and the service persists only the final parsed `data`, `select`,
139
1408
  `populate`, `tasks`, and allowed `options`. Missing body options fall back to `returning_all`/`include_permissions`
@@ -248,9 +1517,15 @@ handling rather than permission fallback.
248
1517
  ```ts
249
1518
  import acl, { type ModelRouterOptions } from '@web-ts-toolkit/access-router';
250
1519
 
1520
+ declare module '@web-ts-toolkit/access-router' {
1521
+ interface AccessRouterRequestExtensions {
1522
+ actor?: { permissions: string[] }; // populated by application authentication middleware
1523
+ }
1524
+ }
1525
+
251
1526
  acl.setGlobalOptions({
252
1527
  globalPermissions(req) {
253
- return req.headers.user === 'admin' ? ['isAdmin', 'canReadDetails'] : [];
1528
+ return req.actor?.permissions ?? [];
254
1529
  },
255
1530
  });
256
1531
  const permissionBased = {
@@ -376,7 +1651,8 @@ Use `set`/`setOption` with undefined to clear a variant; the two-argument proper
376
1651
  helper is intended for defined values.
377
1652
 
378
1653
  `router.options` is the frozen **construction-time** snapshot. Options getter
379
- results are also frozen snapshots; fetch again for current stored values. Ordinary
1654
+ results are also frozen snapshots; data native-valued properties return detached copies
1655
+ on access as described above. Fetch again for current stored values. Ordinary
380
1656
  `getModelOption`/`getDataOption` use generic nested-option fallback, not the
381
1657
  route-specific variant-to-base resolver. Exact getters inspect the variant path
382
1658
  (including exact model-default fallback). Request guards read live runtime values.
@@ -491,16 +1767,46 @@ Root entrypoint (`@web-ts-toolkit/access-router`):
491
1767
  - `guard(...)` and `GuardModelCondition`, `GuardModelConditionID` types
492
1768
  - `combineRoutes(...)` and `createOpenApiRouter(runtime, options)` (note: prefer `acl.createOpenApiRouter(options)` for the default runtime)
493
1769
  - validation adapters: `defineRequestSchema(...)`, `fromZod(...)`, `fromYup(...)`, `fromJoi(...)`, `fromAjv(...)`, `fromStandardSchema(...)`, `fromValibot(...)`, `fromArkType(...)`, `fromIoTs(...)`, `fromSuperstruct(...)`, `fromVine(...)`
494
- - `permissionsPlugin` (src/plugins.ts) — Mongoose schema plugin used as `schema.plugin(permissionsPlugin, { modelName })`
1770
+ - `permissionsPlugin` — optional Mongoose metadata alias; [role and usage](#optional-permissions-plugin)
495
1771
  - logger helpers: `redactFilter`, `redactPayload`, `safeStringify`, `isLevelEnabled` + `OpLogContext` type
496
1772
  - option helpers: `setGlobalOptions`, `setGlobalOption`, `getGlobalOptions`, `getGlobalOption`, `setModelOptions`, `setModelOption`, `getModelOptions`, `getModelOption`, `getModelNames`, `getModelJsonSchema`, `setDefaultModelOptions`, `setDefaultModelOption`, `getDefaultModelOptions`, `getDefaultModelOption`
497
1773
  - 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`.
1774
+ - trusted service policy types (also `/advanced`): `ServiceAuthorization` (reads), `MutationAuthorization` (update/upsert), `CreateAuthorization`, `InputFieldAuthorization`, `InputFieldPath`, `OutputAuthorization`, `FieldAuthorization`, `RowAuthorization`, `OperationAuthorization`, `RelatedAuthorization`, `OutputFieldPath`. Input paths use persisted types only; output paths also include virtuals. Unknown models have an explicit runtime-validated string fallback; grant arrays may be readonly.
1775
+ - named helper/result types (also `/advanced`): `OutputPlanningOptions`, `OutputFieldOptions`, `AllowedOutputFieldOptions`, opaque `DocumentAuthorization`, `ServiceOutputContext`, `ServiceOutputDecorator`, `AuthorizedSingleResult`, `AuthorizedListResult`, `NewArgs`, `NewOptions`. Acquire the service from a router/request; there is no standalone `Service` export or public issuer/execution/prepared API.
498
1776
 
499
1777
  Subpath entrypoints:
500
1778
 
501
1779
  - `@web-ts-toolkit/access-router/advanced` — request/service/option interfaces and include wire types, symbols (`MIDDLEWARE`, `DATA_MIDDLEWARE`, `PERMISSIONS`, ...), enums (`Codes`, `StatusCodes`), and validation helpers (`parseBody`, `parseQuery`, `parseBodyWithSchema`, request schemas). Runtime-context functions are internal; use root exports such as `createAccessRuntime`, `AccessRuntime`, and `defaultRuntime` for runtime ownership. `/advanced` does not export runtime instances or router-creation helpers.
502
1780
  - `@web-ts-toolkit/access-router/processors` — `copyAndDepopulate`, `copyPaths`, `movePaths`, `sliceArrays`, `countPaths`, `maskPaths` and processor option types (`ProcessCopy`, `CopyAndDepopulateOptions`, `CopyAndDepopulateOutput`, `ProcessorOptions`, `SliceProcessorOptions`, `ProcessSlice`, `ProcessCount`, `ProcessMask`, `ProcessorOutput`) for transforming populated documents.
503
1781
 
1782
+ ## Request validation and OpenAPI
1783
+
1784
+ Generated routes already validate their supported envelopes. Supply `requestSchemas`
1785
+ for application parsing; `validate` is a separate admitted-write hook. The root
1786
+ exports adapters for Zod, Yup, Joi, AJV, Standard Schema, Valibot, ArkType, io-ts,
1787
+ Superstruct and Vine. For example, with Zod installed:
1788
+
1789
+ <!-- doc-example: complete-runtime -->
1790
+
1791
+ ```ts
1792
+ import { z } from 'zod';
1793
+ import { fromZod, defineRequestSchema } from '@web-ts-toolkit/access-router';
1794
+ const validator = fromZod(z.object({ name: z.string() }));
1795
+ const schema = defineRequestSchema(validator);
1796
+ ```
1797
+
1798
+ Pass such a schema to the relevant `requestSchemas` key (for example `create` or
1799
+ `advancedCreateData`). [Advanced mutation parsing](#advanced-mutation-validation)
1800
+ describes the nested-data/whole-envelope order and allowed options.
1801
+
1802
+ After registering routers, mount `api.createOpenApiRouter({ title: 'Example API',
1803
+ version: '1.0.0' })` directly with `app.use(...)` — it returns an Express router,
1804
+ not an object with `.routes`. Defaults expose `/openapi.json` and `/docs`;
1805
+ `jsonPath`/`docsPath` customize them and `docsPath: false` disables Swagger UI.
1806
+ The standalone named function is `createOpenApiRouter(api.runtime, options)`;
1807
+ the runtime-bound method takes only `options`. Route guards and server-only
1808
+ authorization policies are not client input fields in the generated schema.
1809
+
504
1810
  ## AJV validation contract
505
1811
 
506
1812
  Install `ajv` in your application when using `fromAjv`; it is not a mandatory
@@ -587,9 +1893,12 @@ adopts that exact global model instance into its registry on first lookup.
587
1893
  Cross-runtime composition on a shared request keeps independent runtime-owned state. When one request passes through
588
1894
  middleware from runtime A and then runtime B, B initializes its own core, resolves its own `globalPermissions`, and
589
1895
  uses its own base-filter cache; it never reuses A's permissions or cached filters, even when both runtimes use the
590
- same `requestPermissionField` name. Repeating the same runtime's middleware on one request reuses that runtime's state
591
- without rerunning its resolver. Values already present in `requestPermissionField` before any runtime middleware runs
592
- are treated as application-supplied and preserved. Migration: if you relied on B silently inheriting A's resolved
1896
+ same `requestPermissionField` name. A sync/async `globalPermissions` result of `null` or `undefined` establishes an empty
1897
+ permission map, replacing any prior runtime-owned grants. Missing or non-callable resolvers follow the same empty-grants
1898
+ rule. Middleware awaits asynchronous resolution before publishing the new core and continuing. Repeating the same
1899
+ runtime's middleware on one request reuses that runtime's state without rerunning its resolver. Permission maps (including
1900
+ `{}`) supplied in `requestPermissionField` before any runtime middleware runs are treated as application-owned and
1901
+ preserved. Migration: if you relied on B silently inheriting A's resolved
593
1902
  permissions on a shared request, set those permissions explicitly before the chain or configure B's `globalPermissions`
594
1903
  to resolve them.
595
1904
 
@@ -603,18 +1912,49 @@ to resolve them.
603
1912
 
604
1913
  ```ts
605
1914
  import mongoose from 'mongoose';
606
- import acl, { permissionsPlugin } from '@web-ts-toolkit/access-router';
1915
+ import { createAccessRuntime } from '@web-ts-toolkit/access-router';
607
1916
 
608
1917
  // `uri` is the MongoDB connection string for the non-default connection.
609
1918
  const uri = process.env.MONGODB_URL_TENANT ?? 'mongodb://localhost:27017/tenant';
610
1919
  const conn = await mongoose.createConnection(uri).asPromise();
611
1920
  const schema = new mongoose.Schema({ name: String });
612
- schema.plugin(permissionsPlugin, { modelName: 'TenantUser' });
613
1921
  const TenantUser = conn.model('TenantUser', schema);
614
1922
 
615
- const tenantRouter = acl.createRouter(TenantUser, { basePath: '/tenant-users' });
1923
+ const api = createAccessRuntime();
1924
+ const tenantRouter = api.createRouter(TenantUser, {
1925
+ basePath: '/tenant-users',
1926
+ operationAccess: { list: true, read: true },
1927
+ permissionSchema: { name: true },
1928
+ });
1929
+ ```
1930
+
1931
+ `createRouter(options)` — the single-options overload creates a root batch router;
1932
+ see [Root batch bounds](#root-batch-bounds). Model/data/root factories return an
1933
+ object whose `.routes` is mounted in Express.
1934
+
1935
+ ### Optional permissions plugin
1936
+
1937
+ `permissionsPlugin` only adds a Mongoose getter alias (default `permissions`) for
1938
+ the model's configured document metadata slot (default `_permissions`). It reads
1939
+ the existing `_doc[documentPermissionField]` value using active-runtime option
1940
+ lookup, falling back to the default runtime outside package-managed execution.
1941
+ It does not compute grants, authenticate an actor, or enable field enforcement;
1942
+ generated routers and services already enforce their configured policies.
1943
+
1944
+ <!-- doc-example: partial -->
1945
+
1946
+ ```ts
1947
+ import mongoose from 'mongoose';
1948
+ import { permissionsPlugin } from '@web-ts-toolkit/access-router';
1949
+
1950
+ const schema = new mongoose.Schema({ name: String });
1951
+ schema.plugin(permissionsPlugin, { modelName: 'User', virtualPermissionField: 'permissions' });
616
1952
  ```
617
1953
 
1954
+ Use it only when application code needs that hydrated-document alias. Package output
1955
+ finalization uses the configured metadata path directly and supports lean results.
1956
+ `virtualPermissionField` is an alias name, not the package's computed `virtuals` API.
1957
+
618
1958
  ## Include Cardinality
619
1959
 
620
1960
  Advanced read/list requests can attach related model data with `include` entries.
@@ -624,10 +1964,26 @@ Advanced read/list requests can attach related model data with `include` entries
624
1964
  Legacy count joins use one grouped aggregate for all parents per include execution.
625
1965
  They count distinct target documents in the authorized count scope: duplicate local
626
1966
  keys, repeated foreign array entries, or one document matching multiple local keys
627
- do not inflate a parent's count. No matches produce `0`.
1967
+ do not inflate a parent's count. The same target may count once for each of several
1968
+ parents. No matches produce `0`. Mongo computes the numbers per requested parent-key
1969
+ group and returns only bounded group ordinals and counts; target IDs are never
1970
+ accumulated or returned. Result size depends on the requested groups, even when the
1971
+ matching targets' IDs would exceed Mongo's BSON document limit.
1972
+
1973
+ Group membership uses Mongo's native query `$in` semantics after schema casting:
1974
+ scalar membership, whole-array and literal-object equality, null/missing fields,
1975
+ and dotted traversal through embedded arrays. Repeated nested matches still count
1976
+ one target once per parent. For example, `countByFieldValues('direct', [['a', 'b']],
1977
+ {}, 'count')` counts a stored `direct: ['a', 'b']` under alias `'a,b'`; a count include
1978
+ joining scalar `'a'` to `contacts.keys` matches `contacts: [{ keys: ['a', 'b'] },
1979
+ { keys: ['a'] }]` once. Legacy include local arrays retain their one-level flattening
1980
+ into group keys; the public helper treats each input value as a single operand.
1981
+ One authorized candidate stream feeds at most 1,000 native match/count facets.
1982
+ Predicate work scales with candidate documents × requested groups; each facet emits
1983
+ at most one numeric row and never collects target documents or IDs.
628
1984
 
629
1985
  The target Mongoose schema casts both the complete authorized filter (including
630
- tenant filters) and foreign-key operands, including after array unwind. ObjectId/String
1986
+ tenant filters) and foreign-key operands used in every group. ObjectId/String
631
1987
  joins and query setters retain association to the original parent keys. Invalid
632
1988
  schema operands/filters produce controlled `BadRequest` (direct HTTP 400; root entry
633
1989
  `statusCode: 400`, `code: 'bad_request'`) rather than misleading zero counts. Count
@@ -640,6 +1996,70 @@ belongs to the aggregate operation. Express router count authorization through
640
1996
  `operationAccess` and row-filter hooks. Arbitrary pipelines passed to
641
1997
  `router.model.aggregate(...)` are not automatically cast.
642
1998
 
1999
+ ### Numeric grouped-count service API (breaking migration)
2000
+
2001
+ `service.countByFieldValues(foreignField, values, filter = {}, access = 'list')`
2002
+ now returns `Promise<SingleResult<Map<string, number>> | ErrorResult>`. Its `values` parameter
2003
+ accepts readonly arrays. Each distinct original `String(value)` alias has a numeric
2004
+ count, including `0` for misses, in first-occurrence order. Equal original aliases
2005
+ use their first operand; different aliases normalized to the same schema value (for
2006
+ example `'001'` and `'1'` on a Number field) receive equal counts. A target with a
2007
+ repeated foreign array value counts once for that alias. Inputs are snapshotted
2008
+ before awaited row hooks.
2009
+
2010
+ **Migration:** replace `result.data.get(key)?.size` with `result.data.get(key) ?? 0`
2011
+ and change `Map<string, Set<string>>` consumers to `Map<string, number>`. There is no
2012
+ ID-set compatibility path. Per-key counts can overlap and must not be summed to
2013
+ obtain a multi-key union: a legacy `op: 'count'` include on an array-valued local field
2014
+ counts that entire parent's union in Mongo. Its grouped executor and prepared-query
2015
+ capabilities remain package-private. For a standalone single union, the existing
2016
+ `service.count({ foreignField: { $in: keys } }, 'count')` expresses one numeric count
2017
+ (use the real field name in the filter).
2018
+
2019
+ <!-- doc-example: partial -->
2020
+
2021
+ ```ts
2022
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
2023
+
2024
+ export async function countInvoiceLines(req: ModelRequest) {
2025
+ const service = req.macl.getService<{ invoiceIds: string[] }>('InvoiceLine');
2026
+ const result = await service.countByFieldValues('invoiceIds', ['invoice-a', 'invoice-b'], {}, 'count');
2027
+ if (!result.success) return result;
2028
+ const invoiceACount: number = result.data.get('invoice-a') ?? 0;
2029
+ return { invoiceACount, counts: Object.fromEntries(result.data) };
2030
+ }
2031
+ ```
2032
+
2033
+ This service helper resolves the requested **row access**, defaulting to `list`;
2034
+ pass `count` to use the count row policy. Custom entrypoints still establish their
2035
+ operation guard. The helper accepts no authorization-options or prepared-token
2036
+ argument. Legacy includes retain the target count operation guard, independent
2037
+ related authority, mandatory join/descriptor predicates and terminal-false behavior.
2038
+
2039
+ `service.count(filter, access = 'list')` likewise returns one numeric `SingleResult`
2040
+ under the requested row policy. In contrast, legacy `countTrusted(authorizedFilter)`
2041
+ expects a complete, already-authorized server predicate: it does not run client
2042
+ parsing or `baseFilter`/`overrideFilter`. It accepts no policy/options argument and
2043
+ still treats false as terminal. It is not the executor used by package includes;
2044
+ those use private, authenticated prepared queries.
2045
+
2046
+ Both entry paths enforce fixed per-execution limits before aggregation: **1,000
2047
+ groups** (parents, or input values for the public helper), **10,000 key occurrences
2048
+ before deduplication**, **64 KiB per BSON-encoded key or original alias**, **1 MiB
2049
+ cumulative BSON-key plus alias bytes**, **depth 8 / 20,000 visited key nodes**, and
2050
+ **1,024 UTF-8 bytes for `foreignField`**. Both original inputs and schema-cast operands
2051
+ expanded into parent groups must fit. Scalar/native BSON equality keys and bounded
2052
+ literal values retain owning-schema casting; regex/executable values are not equality
2053
+ join operands. A plain object operand whose first field begins with `$` also rejects
2054
+ before persistence, as Mongo's query `$in` does not accept expression documents; an
2055
+ object containing both `$ref` and `$id` retains Mongo's literal DBRef exception. Dollar
2056
+ fields inside nested literal objects/arrays, or after an ordinary first field, remain
2057
+ data. These checks apply to raw and schema-cast operands. Exceeding a bound returns
2058
+ controlled `BadRequest`/HTTP 400 without target persistence: counts are never truncated
2059
+ or split into per-parent queries.
2060
+ These limits are independent of list pagination; a parent query above 1,000 rows
2061
+ must be narrowed before requesting a legacy count include.
2062
+
643
2063
  ### Safe include output paths
644
2064
 
645
2065
  Treat `path` as output only: put an invoice's related count at `lineItemCount`, for
@@ -657,6 +2077,16 @@ envelope), even if the relationship would be empty. Malformed reads do not retry
657
2077
  through list access. Ordinary output-field replacement remains supported; generated
658
2078
  include data cannot grant source-field permission.
659
2079
 
2080
+ Include output is tracked separately from the original projected source. Document
2081
+ permission hooks and virtual dependencies see the source values even when a successful
2082
+ attachment replaces the same business field (for example, `restricted`). Only results
2083
+ actually produced by include execution are attached after source selection and field
2084
+ filtering, with the target's own authorization and document handle. A legacy read miss
2085
+ or omitted result never turns an existing source value into an attachment: that field
2086
+ still requires source selection and permission. Correlated empty results remain explicit
2087
+ `null`/`[]`/`0` attachments. This separation also applies to nested targets and each list
2088
+ row; include output does not mutate fetched persistence documents.
2089
+
660
2090
  ## Correlated Includes
661
2091
 
662
2092
  Besides the legacy `localField`/`foreignField` joins, `include` entries accept
@@ -737,8 +2167,8 @@ Semantics:
737
2167
 
738
2168
  The TypeScript wire types (`ParentRef`, `CorrelatedInclude`, `Include`) are
739
2169
  reachable without deep imports via `@web-ts-toolkit/access-router/advanced`
740
- (which re-exports `./interfaces`). See the website `services`,
741
- `configuration`, `validation`, and `openapi` pages for the full contract.
2170
+ (which re-exports the public interfaces). The shipped guide above defines the
2171
+ contract; the website has additional service/configuration/validation examples.
742
2172
 
743
2173
  ## Nested updates and trusted hooks
744
2174
 
@@ -946,6 +2376,15 @@ service boundary. `delete` (identity only), `exists`, `distinct`, `count`, and
946
2376
  `countTrusted` never run getters. No submitted or computed virtual value is
947
2377
  persisted.
948
2378
 
2379
+ Filter cleanup distinguishes field predicates from literal data. Empty-object
2380
+ equality (`{ reconciliation: {} }`), literal arrays, and `$eq`/`$in` operands
2381
+ retain their meaning, including data keys with the same spelling as virtuals.
2382
+ Root/dotted virtual predicates and embedded `$elemMatch` query fields are still
2383
+ excluded. The nested-field shorthand under a registered embedded container also
2384
+ excludes its virtual fields; use `$eq` for literal whole-container equality there.
2385
+ Only clauses emptied by an actual virtual removal are pruned. An originally empty
2386
+ object or logical branch is retained; a wholly virtual ordinary filter becomes `{}`.
2387
+
949
2388
  Trusted `decorate`/`decorateAll`/tasks boundary: virtuals evaluate before
950
2389
  `decorate`/`decorateAll`/tasks; internal-only dependency fields are stripped
951
2390
  before those hooks see the object. Trusted hooks may deliberately construct new
@@ -958,16 +2397,57 @@ data over per-row database calls. On a 24-row list with 2 getters at
958
2397
  25 driver reads (1 list + 24 per-row) with peak active getter work 4; the same
959
2398
  label via `populate: [{ path: 'addrId', select: ['label'] }]` and a getter
960
2399
  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.
2400
+ with identical output and the same peak bound. Bounds do not collapse N+1
2401
+ into 1. Parent getters see only finalized related/embedded outputs and must
2402
+ not rely on a related model's stripped private fields.
2403
+
2404
+ ### Hook admission and reentrant service calls
2405
+
2406
+ `requestComplexity.maxHookConcurrency` (default `10`) limits **independently
2407
+ admitted hook chains per request/runtime**. Independent services, root-batch
2408
+ entries, legacy/correlated includes, populate and embedded/subdocument outputs
2409
+ share one private pool. It covers:
2410
+
2411
+ - Model `docPermissions`, including pre-write, output-helper and owning-parent stages;
2412
+ - Virtual getters;
2413
+ - Model/data `decorate` and `decorateAll` chains and service output decorator callbacks;
2414
+ - Data request-field selection/trimming stages (their field rules run sequentially).
2415
+
2416
+ Other guards, row/identifier filters, model field-rule callbacks and write
2417
+ lifecycle hooks are outside this hook pool. Row/traversal maps have finite
2418
+ **per-call** worker bounds, with stable output/context indices; their waiting
2419
+ orchestration frames are not globally counted as hook permits.
2420
+
2421
+ **Awaiting another service from a hook is supported, including at limit `1`.**
2422
+ Its descendant hooks reuse the admitted chain through private async context.
2423
+ Every live callback scope admits **one immediate child branch at a time**, FIFO,
2424
+ including that child's descendant drain. Thus `Promise.all` of nested service
2425
+ calls cannot bypass the limit: sibling hook stages serialize in that chain.
2426
+ At most `maxHookConcurrency` independent leaf branches are admitted; recursively
2427
+ waiting ancestor callbacks still exist and do **not** consume extra permits.
2428
+ The setting does not promise that all unsettled callback frames fit within it.
2429
+
2430
+ Reentrant depth is capped at **64 admission scopes per chain**, including internal
2431
+ hook-stage wrappers, independently of client template/structure depth limits.
2432
+ Overflow rejects through the existing hook failure policy (getters omit their
2433
+ field, document-grant hooks fail closed, decorator errors propagate). Settlement
2434
+ closes a scope to new reentry and drains already-queued child hooks before releasing
2435
+ its branch, even on error. Detached continuations of closed scopes reacquire normal
2436
+ root admission. Await work started by hooks and avoid cross-branch dependency cycles;
2437
+ sibling hooks must not wait for each other to start. Root and per-frame child queues
2438
+ each transfer permits FIFO; descendants progress within their existing chain before
2439
+ unrelated queued roots.
2440
+
2441
+ Hook and persistence limits are normalized and captured together when the private
2442
+ request/runtime work state is first used. Configuration changes affect new requests;
2443
+ existing owners retain their captured limits regardless of ambient runtime changes.
2444
+ Separate requests and runtimes have independent pools. Service hook delegates retain
2445
+ their originating core when another runtime reinitializes the same request's core slots.
2446
+ `maxBulkConcurrency` remains the separate leaf adapter/document persistence ceiling:
2447
+ no persistence permit is held
2448
+ over hooks or recursive orchestration. Trusted hooks' own direct DB/network fan-out,
2449
+ total queued work and query counts are not bounded by these settings. Neither is a
2450
+ process-wide connection limit or an automatic batching mechanism.
971
2451
 
972
2452
  ### Virtuals compatibility notes
973
2453
 
@@ -995,13 +2475,9 @@ not silent backward compatibility:
995
2475
  `_create` picks by explicit `select` after decorate/tasks and `_update`
996
2476
  picks by explicit `select` (else the implicit `returningAll: false` pick);
997
2477
  `new()` continues to ignore `args.select` (all applicable `create` virtuals
998
- are considered, subject to explicit exclusion). Any future honoring of
2478
+ are considered, subject to field authorization, not `args.select` exclusions). Any future honoring of
999
2479
  `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.
2480
+ - This README is the shipped compatibility and migration record for these contracts.
1005
2481
 
1006
2482
  ## Persistence concurrency and resource limits
1007
2483
 
@@ -1049,26 +2525,12 @@ Persistence admission is shared across nested work within each request/runtime,
1049
2525
  timing may change. It does not provide transactions, idempotency or optimistic
1050
2526
  concurrency protection for competing edits.
1051
2527
 
1052
- ## Import styles
1053
-
1054
- The package ships both a default export and named exports:
1055
-
1056
- <!-- doc-example: partial -->
1057
-
1058
- ```ts
1059
- // default export (preferred for the runtime API)
1060
- import acl from '@web-ts-toolkit/access-router';
1061
-
1062
- // named exports (useful when you only need specific helpers)
1063
- import { createAccessRuntime, fromZod } from '@web-ts-toolkit/access-router';
1064
- ```
1065
-
1066
- ### Subpath import example
2528
+ ## Subpath import example
1067
2529
 
1068
2530
  <!-- doc-example: complete-runtime -->
1069
2531
 
1070
2532
  ```ts
1071
- import { copyAndDepopulate } from '@web-ts-toolkit/access-router/processors';
2533
+ import { copyAndDepopulate, type CopyAndDepopulateOutput } from '@web-ts-toolkit/access-router/processors';
1072
2534
 
1073
2535
  type DepopulatedItems = {
1074
2536
  items: string[];
@@ -1081,6 +2543,9 @@ const { items, itemsSnapshot } = copyAndDepopulate<DepopulatedItems>(
1081
2543
  { mutable: false },
1082
2544
  );
1083
2545
  // `items` is now `['a1']`; `itemsSnapshot` holds the original objects.
2546
+ const conservative: CopyAndDepopulateOutput = copyAndDepopulate({ items: [{ _id: 'a1' }] }, [
2547
+ { src: 'items', dest: 'itemsSnapshot' },
2548
+ ]);
1084
2549
  ```
1085
2550
 
1086
2551
  Without an explicit output type, `copyAndDepopulate(...)` returns the conservative `CopyAndDepopulateOutput`