@web-ts-toolkit/access-router 0.44.0 → 0.46.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
@@ -110,6 +110,29 @@ filter before the base filter is applied, but it is not called for an existing `
110
110
  that denial. Returning `false` from `overrideFilter` also denies the query. Empty or absent filters retain their
111
111
  normal unrestricted meaning unless a base filter restricts them.
112
112
 
113
+ **Model service denial contract:** internal `findOne`/`find`/`updateOne`/`upsert`
114
+ `args.overrides.filter` and `findById`/`updateById` `args.overrides.idFilter` distinguish
115
+ `false` from omitted/`undefined`/`null`. False returns a `forbidden` ErrorResult without
116
+ fallback filter/identifier generation or persistence (including client subqueries).
117
+ Nullish overrides use normal generation. Trusted object filter overrides replace the
118
+ generated filter; object **idFilter** overrides replace identifier resolution but still
119
+ receive row policy. These service arguments are trusted application inputs.
120
+
121
+ `Service.exists(filter, { access: 'read', includeId: false })` returns a successful
122
+ boolean for allowed queries; `includeId: true` returns an `{ _id }` record or `null`.
123
+ Explicit/base/override terminal false now returns `forbidden` **before adapter dispatch**
124
+ in both modes, rather than a successful false/null. An allowed filter that matches no
125
+ visible row is still a successful miss. Check `result.success` before using `result.data`.
126
+
127
+ Public `_read` and `_readFilter` with `tryList: true` (the default) never retry Forbidden
128
+ or BadRequest. An authorized read miss can still fall back to list policy, subject to
129
+ list operation access. This deliberately removes list fallback for terminal read denial.
130
+ Public `_upsert` with an `_id` propagates denied update-policy existence as Forbidden
131
+ (previously Unauthorized); an allowed miss remains Unauthorized and neither case creates.
132
+ Generated direct routes map Forbidden to HTTP 403; root batches use per-entry 403 within
133
+ HTTP 200. **Custom Express routes own HTTP serialization:** returning an ErrorResult or
134
+ calling `res.json(result)` does not automatically set HTTP 403; map its code explicitly.
135
+
113
136
  Advanced mutation validators drive dispatch: nested `data` validators run first, then the whole-body (`default`)
114
137
  validator sees that transformed envelope, and the service persists only the final parsed `data`, `select`,
115
138
  `populate`, `tasks`, and allowed `options`. Missing body options fall back to `returning_all`/`include_permissions`
@@ -126,12 +149,67 @@ Root entrypoint (`@web-ts-toolkit/access-router`):
126
149
  - `guard(...)` and `GuardModelCondition`, `GuardModelConditionID` types
127
150
  - `combineRoutes(...)` and `createOpenApiRouter(runtime, options)` (note: prefer `acl.createOpenApiRouter(options)` for the default runtime)
128
151
  - validation adapters: `defineRequestSchema(...)`, `fromZod(...)`, `fromYup(...)`, `fromJoi(...)`, `fromAjv(...)`, `fromStandardSchema(...)`, `fromValibot(...)`, `fromArkType(...)`, `fromIoTs(...)`, `fromSuperstruct(...)`, `fromVine(...)`
152
+ - `permissionsPlugin` (src/plugins.ts) — Mongoose schema plugin used as `schema.plugin(permissionsPlugin, { modelName })`
153
+ - logger helpers: `redactFilter`, `redactPayload`, `safeStringify`, `isLevelEnabled` + `OpLogContext` type
129
154
  - option helpers: `setGlobalOptions`, `setGlobalOption`, `getGlobalOptions`, `getGlobalOption`, `setModelOptions`, `setModelOption`, `getModelOptions`, `getModelOption`, `getModelNames`, `getModelJsonSchema`, `setDefaultModelOptions`, `setDefaultModelOption`, `getDefaultModelOptions`, `getDefaultModelOption`
130
155
 
131
156
  Subpath entrypoints:
132
157
 
133
- - `@web-ts-toolkit/access-router/advanced` — low-level runtime context, symbols (`MIDDLEWARE`, `PERMISSIONS`, ...), enums (`Codes`, `StatusCodes`), internals (`parseBody`, `parseQuery`, request schemas). Does NOT export `acl`, `defaultRuntime`, or router-creation helpers.
134
- - `@web-ts-toolkit/access-router/processors` — `copyAndDepopulate` and its `ProcessCopy` / `CopyAndDepopulateOptions` / `CopyAndDepopulateOutput` types for transforming populated documents.
158
+ - `@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.
159
+ - `@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.
160
+
161
+ ## AJV validation contract
162
+
163
+ Install `ajv` in your application when using `fromAjv`; it is not a mandatory
164
+ access-router runtime dependency. Import the helper and `AjvValidatorLike` type
165
+ from `@web-ts-toolkit/access-router`.
166
+
167
+ - Synchronous validators return boolean **verdicts**: true returns the input
168
+ (including any in-place AJV mutations), false returns normalized issues. Mutable
169
+ `validate.errors` are snapshotted before any suspension or next validation call.
170
+ - Validators tagged `$async: true` return promises/thenables of **validated data**.
171
+ Every fulfillment succeeds, including `false`, `true`, `null`, scalars and objects;
172
+ the fulfilled value is returned, not substituted with the input. Shared
173
+ `validate.errors` is never read on this path. Real AJV `ValidationError` rejections
174
+ (`ajv: true`, `validation: true`, an `errors` array) provide input-local diagnostics
175
+ under concurrency. Other exceptions propagate unchanged as operational failures.
176
+ - Untagged promises/thenables now reject with a configuration `TypeError`; their
177
+ rejections are observed to avoid unhandled rejection. Sync non-boolean results
178
+ also reject with `TypeError`.
179
+
180
+ <!-- doc-example: complete-runtime -->
181
+
182
+ ```ts
183
+ import { Ajv, type AsyncSchema } from 'ajv';
184
+ import { fromAjv } from '@web-ts-toolkit/access-router';
185
+
186
+ const ajv = new Ajv();
187
+ const sync = fromAjv<boolean>(ajv.compile<boolean>({ type: 'boolean' }));
188
+ const asyncSchema: AsyncSchema = { $async: true, type: 'boolean' };
189
+ const asyncData = fromAjv(ajv.compile<boolean>(asyncSchema));
190
+ const syncResult = await sync(false);
191
+ const asyncResult = await asyncData(false);
192
+ if (!syncResult.success || syncResult.data !== false || !asyncResult.success || asyncResult.data !== false) {
193
+ throw new Error('Both AJV validators must preserve valid false data');
194
+ }
195
+ ```
196
+
197
+ **Migration:** pass genuine compiled AJV sync/async validators directly, without
198
+ casts or wrappers that discard `$async`. `AjvValidatorLike<T>` now separates sync
199
+ boolean and tagged async data signatures; use a literal tag (`$async: true as const`)
200
+ for structural async adapters. An async boolean-verdict wrapper must instead return
201
+ the original validated data or reject with an AJV validation error; merely adding
202
+ the tag changes false/true into successful data. Alternatively implement a
203
+ `RequestSchemaValidator` returning `{ success, data }` / `{ success: false, issues }`.
204
+ Rejections with only an `errors` property are operational, not AJV validation errors.
205
+ AJV's overloaded `AsyncValidateFunction<T>` extends its sync `ValidateFunction<T>`;
206
+ TypeScript can erase the tag in that base type, so runtime checks remain necessary.
207
+ Genuine AJV sync/async output types are inferred via their type-guard overload;
208
+ structural async output is inferred from the promise. Use `fromAjv<T>(syncValidator)`
209
+ for a structural boolean validator without a type guard. An inline schema can select AJV's earlier
210
+ sync overload even with `$async: true`; an `AsyncSchema`-typed schema, as above,
211
+ selects its async overload without casts (also for `compileAsync`). Runtime
212
+ behavior follows the validator's actual tag in either case.
135
213
 
136
214
  ## Default runtime vs. isolated runtime
137
215
 
@@ -200,6 +278,249 @@ Advanced read/list requests can attach related model data with `include` entries
200
278
  `op: 'count'` returns exact authorized counts and ignores include pagination fields such as `args.limit`.
201
279
  `op: 'list'` materializes authorized related rows through the target model's normal list path, so target `limit`, `page`, `pageSize`, and `listHardLimit` bounds still apply to included rows.
202
280
 
281
+ Legacy count joins use one grouped aggregate for all parents per include execution.
282
+ They count distinct target documents in the authorized count scope: duplicate local
283
+ keys, repeated foreign array entries, or one document matching multiple local keys
284
+ do not inflate a parent's count. No matches produce `0`.
285
+
286
+ The target Mongoose schema casts both the complete authorized filter (including
287
+ tenant filters) and foreign-key operands, including after array unwind. ObjectId/String
288
+ joins and query setters retain association to the original parent keys. Invalid
289
+ schema operands/filters produce controlled `BadRequest` (direct HTTP 400; root entry
290
+ `statusCode: 400`, `code: 'bad_request'`) rather than misleading zero counts. Count
291
+ operation denials still prevent target work; a terminal `false` count row policy
292
+ retains legacy omitted include output and a grouped-service Forbidden result.
293
+
294
+ This is query-schema/setter parity, not execution of `countDocuments` middleware or
295
+ plugins: casting is synchronous and performs no query. Aggregate middleware still
296
+ belongs to the aggregate operation. Express router count authorization through
297
+ `operationAccess` and row-filter hooks. Arbitrary pipelines passed to
298
+ `router.model.aggregate(...)` are not automatically cast.
299
+
300
+ ### Safe include output paths
301
+
302
+ Treat `path` as output only: put an invoice's related count at `lineItemCount`, for
303
+ example, rather than inside permission metadata. Legacy and correlated read/list/count
304
+ paths must not equal, descend from, or be an ancestor of the receiving model's
305
+ `documentPermissionField` (default `_permissions`). With `auth.policy.permissions`,
306
+ `auth`, `auth.policy`, and `auth.policy.permissions.canReadSecret` all reject;
307
+ `auth.policy.permissionsExtra` is an ordinary sibling. Equivalent legacy bracket
308
+ paths follow the same rule.
309
+
310
+ The include tree is preflighted before target persistence, including every supported
311
+ nested level using that receiving target's configuration. Overlaps return BadRequest
312
+ (direct HTTP 400; affected root entry 400/`bad_request` within its HTTP 200 batch
313
+ envelope), even if the relationship would be empty. Malformed reads do not retry
314
+ through list access. Ordinary output-field replacement remains supported; generated
315
+ include data cannot grant source-field permission.
316
+
317
+ ## Correlated Includes
318
+
319
+ Besides the legacy `localField`/`foreignField` joins, `include` entries accept
320
+ a correlated variant (`mode: 'correlated'`) that runs a target query **per
321
+ parent document** using values from that parent. References are explicit
322
+ structural markers — `{ "$parent": "<field>" }`, dotted paths allowed —
323
+ recognized only in filter _value_ positions (bare values, field operators
324
+ such as `$eq`/`$in`, `$in` array elements, `$and`/`$or`/`$nor` clauses,
325
+ `$elemMatch` subtrees) and as the whole `id` of identifier reads. Plain
326
+ strings are never references: `'$special'` keeps its literal query meaning.
327
+ Match a literal `{ "$parent": "x" }` object with
328
+ `{ "$escape": { "$parent": "x" } }`. In a bare field position,
329
+ `{ "note": { "$escape": { "$parent": "x" } } }` resolves to
330
+ `{ "note": { "$eq": { "$parent": "x" } } }`, just like bare substituted
331
+ parent objects. The explicit spelling
332
+ `{ "note": { "$eq": { "$escape": { "$parent": "x" } } } }` remains
333
+ equivalent. Operator operands and array elements receive the literal object
334
+ without another `$eq` wrapper. Escapes are resolved once and never read parent
335
+ data; missing, null or changed `parent.x` cannot change the literal. Malformed
336
+ escapes are rejected, and expanded operands still consume complexity budgets.
337
+ This bare-position correction requires an updated server; older servers with
338
+ the bare-escape defect still require the explicit `$eq` spelling.
339
+
340
+ Wire shapes (the originating method fixes `op`; there is no override):
341
+
342
+ ```json
343
+ { "mode": "correlated", "model": "Org", "op": "read", "path": "org", "id": { "$parent": "orgId" } }
344
+ { "mode": "correlated", "model": "Org", "op": "read", "path": "org",
345
+ "filter": { "_id": { "$parent": "orgId" }, "active": true }, "args": { "select": ["name"] } }
346
+ { "mode": "correlated", "model": "Post", "op": "list", "path": "posts",
347
+ "filter": { "authorId": { "$parent": "_id" }, "title": "$special" },
348
+ "args": { "select": ["title"], "sort": { "createdAt": -1 }, "limit": 5 } }
349
+ { "mode": "correlated", "model": "Post", "op": "count", "path": "postCount",
350
+ "filter": { "authorId": { "$parent": "_id" } } }
351
+ ```
352
+
353
+ Semantics:
354
+
355
+ - References resolve against the **immediate parent** document as fetched
356
+ from persistence (including internally loaded reference fields), before
357
+ include attachment, `decorate` hooks, and task mutation. Nested
358
+ `args.include` entries bind to the target doc of the enclosing include.
359
+ Sibling include output never feeds references.
360
+ - A missing (`undefined`) or `null` reference short-circuits that parent's
361
+ execution to the no-match shape with no target query: `read` attaches
362
+ `null`, `list` attaches `[]`, `count` attaches `0`. Target misses produce
363
+ the same shapes. The output path is always set.
364
+ - Identifier reads use the target's configured identifier behavior
365
+ (`resolveIdentifierFilter`/`genIDFilter`, custom `resolveIdFilter`
366
+ honored) with no read-to-list fallback. Counts use explicit count access
367
+ with count semantics, independent of any list limit. List pagination
368
+ (`skip`/`limit`/`page`/`pageSize`) applies **per parent** — contrast with
369
+ legacy batched list includes, whose pagination applies across the parent
370
+ set. Substituted parent values are data: objects match literally (never
371
+ become operators) and arrays are never flattened.
372
+ - Each correlated entry counts toward `maxIncludeCount`; expanded filters
373
+ are revalidated against `maxNodes`/`maxDepth`/`maxInValues`/
374
+ `maxLogicalClauses`. Total inner executions per request/runtime are bounded by
375
+ `maxCorrelatedQueries` (default `100`) and nesting by
376
+ `maxCorrelatedDepth` (default `5`); exceeding any bound fails the whole
377
+ request/affected root entry. Fan-out shares request-and-runtime-owned
378
+ persistence admission (see below). Target authorization denials fail the
379
+ request/affected root entry with zero target persistence queries; target
380
+ runtime errors also fail that request/entry.
381
+ `path` must be a non-empty valid field path (not `_id`, not `$`-prefixed);
382
+ duplicates in one include array are `BadRequest`; collisions with parent
383
+ fields overwrite except for the permission-metadata overlaps described above.
384
+ - Referenced parent fields are added to the parent DB select like legacy
385
+ `localField`s, then trimmed unless allowed by field policy and selected.
386
+ A policy-forbidden reference still resolves — only its query effects are
387
+ visible, never the value.
388
+ - Direct count parents carry no `include`; correlated _count_ includes
389
+ attach to read/list parents only. Correlated entries are accepted in both
390
+ direct (`model-router.ts`) and root (`root-router.ts`) validators with
391
+ identical verdicts; malformed correlated input is a controlled
392
+ `BadRequest`, never a silent drop. Legacy entries also enforce safe output paths, and
393
+ legacy-shaped entries carrying `$parent` markers are rejected.
394
+
395
+ The TypeScript wire types (`ParentRef`, `CorrelatedInclude`, `Include`) are
396
+ reachable without deep imports via `@web-ts-toolkit/access-router/advanced`
397
+ (which re-exports `./interfaces`). See the website `services`,
398
+ `configuration`, `validation`, and `openapi` pages for the full contract.
399
+
400
+ ## Nested updates and trusted hooks
401
+
402
+ Partial updates follow **authorized policy paths**, not a generic deep merge. For a
403
+ customer profile editor, grant only the editable leaf and send nested JSON:
404
+
405
+ <!-- doc-example: partial -->
406
+
407
+ ```ts
408
+ import type { ModelRouterOptions } from '@web-ts-toolkit/access-router';
409
+
410
+ // Non-generic options accept dotted policy paths.
411
+ const options: ModelRouterOptions = {
412
+ operationAccess: { update: true },
413
+ permissionSchema: { 'profile.public': { update: true } },
414
+ };
415
+ const updateBody = { profile: { public: 'New display name' } };
416
+ // Pass options to acl.createRouter(CustomerModel, options); send updateBody to PATCH.
417
+ // Existing profile.secret and omitted siblings survive.
418
+ void [options, updateBody];
419
+ ```
420
+
421
+ This applies to `updateOne`, `updateById`, existing-row upsert, and single/bulk
422
+ subdocument updates, including Mongoose nested and single-nested schemas. Authorized
423
+ leaves can initialize missing/null containers. Authorizing `profile` itself instead
424
+ allows whole replacement and removes omitted children; whole grants take priority
425
+ over overlapping leaf grants. An authorized object leaf replaces just that leaf.
426
+ Arrays replace as a whole when authorized as a whole; an explicit `items.0.public`
427
+ grant patches that indexed leaf. Omitted fields remain unchanged, admitted `null`
428
+ clears the selected field, and authorized empty objects/arrays replace it (Mongoose
429
+ `minimize` may omit empty objects in storage). A null/empty/malformed client container
430
+ does not supply descendant leaves and cannot clear a protected parent. Casting and
431
+ validation still apply. Literal dotted/bracket client keys are ignored by field
432
+ selection; they are not `$set`/`$unset` instructions.
433
+
434
+ Model `prepare` and `transform` hooks are trusted application code. Validation sees
435
+ selected client data before prepare. Prepare output is not re-filtered: it can add
436
+ protected/server-only fields, including dotted Mongoose paths. At strict ancestors of
437
+ admitted paths, supplied children of plain-object/array prepare output are applied
438
+ while omitted siblings survive. Other prepare paths remain whole assignments.
439
+ An empty partial container or omitted parent does not delete siblings; explicit
440
+ `{ profile: null }` / `{ profile: undefined }` can clear/unset it. An entirely
441
+ null/undefined prepare result supplies no assignments.
442
+
443
+ For deliberate parent replacement, trusted `transform.update` can call
444
+ `doc.set('profile', replacement)`. Do not blindly reintroduce unfiltered
445
+ `context.originalData`. The update order remains validation → prepare →
446
+ assigned-document transform → save → afterPersist → changes; snapshots/diffs reflect
447
+ persisted changes. Subdocument operations retain their own lifecycle and do not gain
448
+ model prepare/transform hooks.
449
+
450
+ ## Subdocument writes with hidden responses
451
+
452
+ Appending an attachment or updating a line item can succeed without permission to
453
+ read it. After saving, mutation responses require the parent's read operation/row
454
+ access, checked against post-save state. Full-array **create** responses (including
455
+ POST `[]`) additionally require both `subs.<sub>.list` and `.read` operation guards
456
+ and row filters. **Single/bulk update** responses require subdocument read access
457
+ only and return readable mutation targets; they do not require list access.
458
+
459
+ All three use read-field projection plus `_id`. Counts reflect visible returned
460
+ rows, not stored/updated totals. Filtering preserves stored array order, including
461
+ service `addFirst` insertion order; bulk request order does not reorder the response.
462
+ `addFirst` remains a service option, not a new generated HTTP option.
463
+
464
+ When a response guard denies access, a row policy returns `false`, or no readable
465
+ parent/rows match, the committed write still succeeds:
466
+
467
+ | Operation | Service/root result fields | Direct HTTP body/status; root entry status |
468
+ | ------------- | ------------------------------------------------------------------ | ------------------------------------------ |
469
+ | Create | `success: true, kind: 'list', code: 'created', data: [], count: 0` | `[]`, 201; 201 |
470
+ | Bulk update | `success: true, kind: 'list', code: 'success', data: [], count: 0` | `[]`, 200; 200 |
471
+ | Single update | `success: true, kind: 'single', code: 'success', data: null` | JSON `null`, 200; 200 |
472
+
473
+ Root batches retain their HTTP 200 envelope. **Do not retry a successful write just
474
+ because its output is empty.** Actual write denials and validation/persistence failures
475
+ retain their errors. Response visibility needs at most one extra parent query and
476
+ resolves applicable row policies once per mutation, rather than once per returned row.
477
+
478
+ ## Persistence concurrency and resource limits
479
+
480
+ `requestComplexity.maxBulkConcurrency` (default `10`) bounds simultaneous awaited
481
+ **adapter/document persistence operations** for access-router model services sharing
482
+ one request and runtime. Direct service calls, root entries, subqueries, legacy
483
+ includes and nested correlated includes share admission. Different requests and
484
+ different runtimes have independent pools and correlated-query totals. Services
485
+ capture their owning runtime at construction; later ambient runtime changes do not
486
+ move their persistence to another pool.
487
+
488
+ Admission covers lazy adapter query settlement, document save/delete, and
489
+ service-triggered post-write populate. Bulk create submits one admitted singleton-array
490
+ adapter call per item, preserves result order, and still submits every item if one
491
+ rejects. Internal custom adapters must preserve array-in/array-out create semantics.
492
+ Their optional synchronous `castFilter` must cast a copy and preserve `$in` operand
493
+ order/cardinality; without it they retain identity casting.
494
+
495
+ Recursive orchestration holds no persistence permit while waiting for descendants,
496
+ so limit `1` supports nested includes. Internal map/run loops remain bounded per
497
+ invocation. `RootRouter.maxConcurrentOperations` separately bounds whole root
498
+ operations within each order group, with results in input-index order. Permits
499
+ release on success or error and pass FIFO to waiting work; release neither cancels
500
+ submitted siblings nor refunds the cumulative `maxCorrelatedQueries` budget (default
501
+ `100`). `maxCorrelatedDepth` (default `5`) separately bounds template nesting.
502
+
503
+ The ceiling is **not a MongoDB driver-command count**: one admitted operation can
504
+ run Mongoose middleware/populate that issues multiple commands. Router ACL, prepare,
505
+ afterPersist, decorate and recursive include orchestration are outside the permit;
506
+ Mongoose's own middleware runs inside the awaited operation. Direct database/network
507
+ calls from trusted hooks are not governed. This is neither a process-wide connection
508
+ pool limit nor a throughput guarantee. Controlled nested-query instrumentation reduced
509
+ peak active collection calls from 9 to 3 at limit 3 for the tested paths; it is not a
510
+ production benchmark or a guarantee about middleware/populate fan-out.
511
+
512
+ ### Compatibility notes
513
+
514
+ These boundary fixes preserve public entrypoints and require no new dependency or
515
+ option. Move computed include output away from permission metadata. Nested leaf edits
516
+ now preserve protected siblings; prepare hooks relying on omission to delete a parent
517
+ must replace it explicitly in transform. Handle successful null/empty subdocument
518
+ responses as completed writes. Legacy counts now cast schema values and report invalid
519
+ operands as BadRequest; they remain grouped and independent of list pagination.
520
+ Persistence admission is shared across nested work within each request/runtime, so
521
+ timing may change. It does not provide transactions, idempotency or optimistic
522
+ concurrency protection for competing edits.
523
+
203
524
  ## Import styles
204
525
 
205
526
  The package ships both a default export and named exports: