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