@web-ts-toolkit/access-router 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,370 +8,19 @@ Access-policy Express routers and in-memory data services for Mongoose-backed AP
8
8
  pnpm add @web-ts-toolkit/access-router express mongoose
9
9
  ```
10
10
 
11
- ## Usage
11
+ ## Documentation
12
+
13
+ - Full package documentation lives in `website/docs/packages/access-router/`.
14
+ - Browse the docs site from `website` to read the full guide alongside the rest of the workspace packages.
15
+
16
+ ## Minimal Example
12
17
 
13
18
  ```ts
14
19
  import acl from '@web-ts-toolkit/access-router';
15
20
 
16
- acl.set('globalPermissions', (req) => {
17
- return req.headers.user === 'admin' ? ['isAdmin'] : [];
18
- });
19
-
20
21
  const router = acl.createDataRouter('fruit', {
21
22
  basePath: '/fruit',
23
+ idField: 'id',
22
24
  data: [{ id: 'apple', name: 'Apple', public: true }],
23
- identifier: 'id',
24
- routeGuard: {
25
- list: true,
26
- read: true,
27
- },
28
- permissionSchema: {
29
- id: true,
30
- name: 'isAdmin',
31
- public: true,
32
- },
33
- });
34
- ```
35
-
36
- ## List Responses
37
-
38
- List endpoints now return a stable envelope:
39
-
40
- ```json
41
- {
42
- "data": [],
43
- "meta": {
44
- "returnedCount": 0,
45
- "skip": 0,
46
- "limit": 25,
47
- "page": 1,
48
- "pageSize": 25,
49
- "hasPreviousPage": false
50
- }
51
- }
52
- ```
53
-
54
- When `include_count=true` is enabled, `meta` also includes total pagination information:
55
-
56
- ```json
57
- {
58
- "data": [],
59
- "meta": {
60
- "returnedCount": 0,
61
- "totalCount": 100,
62
- "skip": 25,
63
- "limit": 25,
64
- "page": 2,
65
- "pageSize": 25,
66
- "totalPages": 4,
67
- "hasNextPage": true,
68
- "hasPreviousPage": true
69
- }
70
- }
71
- ```
72
-
73
- Notes:
74
-
75
- - `returnedCount` is the number of rows in this response.
76
- - `totalCount` is only included when `include_count=true`.
77
- - `include_extra_headers=true` can still add the total count header, but it does not change the response body shape.
78
-
79
- When `include_extra_headers=true` is enabled, the response can also include these headers:
80
-
81
- - `wtt-returned-count`
82
- - `wtt-page`
83
- - `wtt-page-size`
84
- - `wtt-has-previous-page`
85
-
86
- And when `include_count=true` is also enabled:
87
-
88
- - `wtt-total-count`
89
- - `wtt-total-pages`
90
- - `wtt-has-next-page`
91
-
92
- ## Request Validation
93
-
94
- Public router endpoints now validate request path params, known query params, and top-level request body shapes before calling the service layer.
95
-
96
- Examples:
97
-
98
- - `GET /users/:id?try_list=false` validates `id` and `try_list`
99
- - `GET /pets?include_count=true&limit=10` validates boolean and pagination query params
100
- - `POST /users/__mutation` requires a top-level `data` field
101
- - `POST /users/__query/:id` validates advanced `select`, `populate`, `include`, and `tasks` shapes
102
-
103
- Invalid requests return `400 application/problem+json` with structured `errors` entries using `parameter` or `pointer`:
104
-
105
- ```json
106
- {
107
- "title": "Bad Request",
108
- "detail": "Bad Request",
109
- "status": 400,
110
- "errors": [
111
- {
112
- "parameter": "include_count",
113
- "detail": "Invalid option: expected one of \"true\"|\"false\""
114
- }
115
- ]
116
- }
117
- ```
118
-
119
- ## User-Defined Request Schemas
120
-
121
- Model and data routers can add route-specific Zod validation through the `requestSchemas` option.
122
-
123
- Use this when you want stricter application-level request validation on top of the built-in router boundary validation.
124
-
125
- Recommended shape:
126
-
127
- - whole-body schemas: `requestSchemas.<route>` or `requestSchemas.<route>.default`
128
- - nested advanced mutation payloads: `requestSchemas.<route>.data`
129
-
130
- Model router examples:
131
-
132
- - `requestSchemas.create`
133
- - `requestSchemas.update`
134
- - `requestSchemas.upsert`
135
- - `requestSchemas.count`
136
- - `requestSchemas.distinct`
137
- - `requestSchemas.advancedList`
138
- - `requestSchemas.advancedReadFilter`
139
- - `requestSchemas.advancedRead`
140
- - `requestSchemas.advancedCreate.default`
141
- - `requestSchemas.advancedCreate.data`
142
- - `requestSchemas.advancedUpdate.default`
143
- - `requestSchemas.advancedUpdate.data`
144
- - `requestSchemas.advancedUpsert.default`
145
- - `requestSchemas.advancedUpsert.data`
146
- - `requestSchemas.subList`
147
- - `requestSchemas.subRead`
148
- - `requestSchemas.subCreate`
149
- - `requestSchemas.subUpdate`
150
- - `requestSchemas.subBulkUpdate`
151
-
152
- Data router examples:
153
-
154
- - `requestSchemas.advancedList`
155
- - `requestSchemas.advancedReadFilter`
156
- - `requestSchemas.advancedRead`
157
-
158
- Example:
159
-
160
- ```ts
161
- import { z } from 'zod';
162
- import acl from '@web-ts-toolkit/access-router';
163
-
164
- const router = acl.createRouter('User', {
165
- basePath: '/users',
166
- identifier: 'name',
167
- requestSchemas: {
168
- create: z.object({
169
- name: z.string().min(3),
170
- role: z.string(),
171
- }),
172
- advancedCreate: {
173
- data: z.object({
174
- name: z.string().min(3),
175
- role: z.literal('user'),
176
- }),
177
- },
178
- advancedUpdate: {
179
- data: z.object({
180
- role: z.enum(['manager', 'staff']),
181
- }),
182
- },
183
- },
184
- });
185
- ```
186
-
187
- Validation order:
188
-
189
- - built-in route/query/body-shape validation runs first
190
- - user-defined `requestSchemas` validation runs second
191
- - write-operation model `validate` hooks still run afterward in the service layer
192
-
193
- ## Custom Route Validation
194
-
195
- The package also exports the same validation helpers used by the built-in public routers:
196
-
197
- - `parsePathParam`
198
- - `parseQuery`
199
- - `parseBody`
200
- - `requestSchemas`
201
- - advanced body schemas such as `listBodySchema`, `readByIdBodySchema`, `advancedCreateBodySchema`, and `advancedUpdateBodySchema`
202
-
203
- Example:
204
-
205
- ```ts
206
- import acl, {
207
- parseBody,
208
- parsePathParam,
209
- parseQuery,
210
- requestSchemas,
211
- readByIdBodySchema,
212
- } from '@web-ts-toolkit/access-router';
213
-
214
- const router = acl.createRouter('User', {
215
- basePath: '/users',
216
- });
217
-
218
- router.router.post('/custom/:id', async (req) => {
219
- const id = parsePathParam(req.params.id, 'id');
220
- const { include_permissions } = parseQuery(requestSchemas.readQuery, req.query);
221
- const body = parseBody(readByIdBodySchema, req.body);
222
-
223
- return {
224
- id,
225
- includePermissions: include_permissions === 'true',
226
- body,
227
- };
228
- });
229
- ```
230
-
231
- These helpers throw the same `BadRequestError` shape as the built-in router endpoints, so custom routes can stay consistent with the package defaults.
232
-
233
- ## Hook Signatures
234
-
235
- The most common model hooks are called with `this` bound to the current Express request.
236
-
237
- ### `baseFilter`
238
-
239
- ```ts
240
- (this: express.Request, permissions: Permissions) =>
241
- | Filter
242
- | true
243
- | null
244
- | undefined
245
- | Promise<Filter | true | null | undefined>
246
- ```
247
-
248
- - Return a `Filter` to restrict access.
249
- - Return `true`, `null`, or `undefined` for no extra base filter.
250
- - Return `false` to deny access.
251
-
252
- ### `decorate`
253
-
254
- ```ts
255
- (this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
256
- unknown | Promise<unknown>;
257
- ```
258
-
259
- - Runs after a document has been loaded and trimmed.
260
- - Can also be an array of middleware functions.
261
-
262
- ### `overrideFilter`
263
-
264
- ```ts
265
- (this: express.Request, filter: Filter, permissions: Permissions) => Filter | Promise<Filter>;
266
- ```
267
-
268
- - Runs before the base filter is applied.
269
- - Use it to rewrite or augment the caller-provided filter.
270
-
271
- ### `validate`
272
-
273
- ```ts
274
- (this: express.Request, allowedData: unknown, permissions: Permissions, context: MiddlewareContext) => boolean | unknown[] | Promise<boolean | unknown[]>
275
- ```
276
-
277
- - Return `true` to allow the write.
278
- - Return `false` to reject it.
279
- - Return an array to provide validation errors.
280
-
281
- ### `prepare`
282
-
283
- ```ts
284
- (this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
285
- unknown | Promise<unknown>;
286
- ```
287
-
288
- - Runs before create/update data is assigned to the document.
289
- - Can also be an array of middleware functions.
290
-
291
- ### `transform`
292
-
293
- ```ts
294
- (this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
295
- unknown | Promise<unknown>;
296
- ```
297
-
298
- - Runs during update flows before the document is saved.
299
- - Can also be an array of middleware functions.
300
-
301
- ### `finalize`
302
-
303
- ```ts
304
- (this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
305
- unknown | Promise<unknown>;
306
- ```
307
-
308
- - Runs after create/update persistence work and before response decoration.
309
- - Can also be an array of middleware functions.
310
-
311
- ### `docPermissions`
312
-
313
- ```ts
314
- (this: express.Request, doc: unknown, permissions: Permissions, context: MiddlewareContext) =>
315
- Record<string, unknown> | Promise<Record<string, unknown>>;
316
- ```
317
-
318
- - Returns the document-level permission object written to the configured permission field.
319
-
320
- ### Example
321
-
322
- ```ts
323
- acl.createModelRouter('Post', {
324
- baseFilter: {
325
- read(this, permissions) {
326
- if (permissions.has('isAdmin')) return true;
327
- return { published: true };
328
- },
329
- },
330
- validate: {
331
- create(this, data) {
332
- if (!data || typeof data !== 'object' || !('title' in data)) {
333
- return ['title is required'];
334
- }
335
-
336
- return true;
337
- },
338
- },
339
- prepare: {
340
- create(this, data) {
341
- if (typeof data === 'object' && data) {
342
- return { ...data, createdAt: new Date() };
343
- }
344
-
345
- return data;
346
- },
347
- },
348
- transform: {
349
- update(this, doc) {
350
- return doc;
351
- },
352
- },
353
- finalize: {
354
- update(this, doc) {
355
- return doc;
356
- },
357
- },
358
- decorate: {
359
- read(this, doc) {
360
- const record = typeof doc === 'object' && doc ? doc : {};
361
- return { ...record, summary: '...' };
362
- },
363
- },
364
- overrideFilter: {
365
- read(this, filter) {
366
- return filter ?? {};
367
- },
368
- },
369
- docPermissions: {
370
- read(this, doc, permissions) {
371
- return {
372
- canArchive: permissions.has('isAdmin'),
373
- };
374
- },
375
- },
376
25
  });
377
26
  ```