@web-ts-toolkit/access-router
v0.2.0
Published
Access-policy Express routers and in-memory data services for Mongoose-backed APIs
Maintainers
Readme
@web-ts-toolkit/access-router
Access-policy Express routers and in-memory data services for Mongoose-backed APIs.
Installation
pnpm add @web-ts-toolkit/access-router express mongooseUsage
import acl from '@web-ts-toolkit/access-router';
acl.set('globalPermissions', (req) => {
return req.headers.user === 'admin' ? ['isAdmin'] : [];
});
const router = acl.createDataRouter('fruit', {
basePath: '/fruit',
data: [{ id: 'apple', name: 'Apple', public: true }],
identifier: 'id',
routeGuard: {
list: true,
read: true,
},
permissionSchema: {
id: true,
name: 'isAdmin',
public: true,
},
});List Responses
List endpoints now return a stable envelope:
{
"data": [],
"meta": {
"returnedCount": 0,
"skip": 0,
"limit": 25,
"page": 1,
"pageSize": 25,
"hasPreviousPage": false
}
}When include_count=true is enabled, meta also includes total pagination information:
{
"data": [],
"meta": {
"returnedCount": 0,
"totalCount": 100,
"skip": 25,
"limit": 25,
"page": 2,
"pageSize": 25,
"totalPages": 4,
"hasNextPage": true,
"hasPreviousPage": true
}
}Notes:
returnedCountis the number of rows in this response.totalCountis only included wheninclude_count=true.include_extra_headers=truecan still add the total count header, but it does not change the response body shape.
When include_extra_headers=true is enabled, the response can also include these headers:
wtt-returned-countwtt-pagewtt-page-sizewtt-has-previous-page
And when include_count=true is also enabled:
wtt-total-countwtt-total-pageswtt-has-next-page
Request Validation
Public router endpoints now validate request path params, known query params, and top-level request body shapes before calling the service layer.
Examples:
GET /users/:id?try_list=falsevalidatesidandtry_listGET /pets?include_count=true&limit=10validates boolean and pagination query paramsPOST /users/__mutationrequires a top-leveldatafieldPOST /users/__query/:idvalidates advancedselect,populate,include, andtasksshapes
Invalid requests return 400 application/problem+json with structured errors entries using parameter or pointer:
{
"title": "Bad Request",
"detail": "Bad Request",
"status": 400,
"errors": [
{
"parameter": "include_count",
"detail": "Invalid option: expected one of \"true\"|\"false\""
}
]
}User-Defined Request Schemas
Model and data routers can add route-specific Zod validation through the requestSchemas option.
Use this when you want stricter application-level request validation on top of the built-in router boundary validation.
Recommended shape:
- whole-body schemas:
requestSchemas.<route>orrequestSchemas.<route>.default - nested advanced mutation payloads:
requestSchemas.<route>.data
Model router examples:
requestSchemas.createrequestSchemas.updaterequestSchemas.upsertrequestSchemas.countrequestSchemas.distinctrequestSchemas.advancedListrequestSchemas.advancedReadFilterrequestSchemas.advancedReadrequestSchemas.advancedCreate.defaultrequestSchemas.advancedCreate.datarequestSchemas.advancedUpdate.defaultrequestSchemas.advancedUpdate.datarequestSchemas.advancedUpsert.defaultrequestSchemas.advancedUpsert.datarequestSchemas.subListrequestSchemas.subReadrequestSchemas.subCreaterequestSchemas.subUpdaterequestSchemas.subBulkUpdate
Data router examples:
requestSchemas.advancedListrequestSchemas.advancedReadFilterrequestSchemas.advancedRead
Example:
import { z } from 'zod';
import acl from '@web-ts-toolkit/access-router';
const router = acl.createRouter('User', {
basePath: '/users',
identifier: 'name',
requestSchemas: {
create: z.object({
name: z.string().min(3),
role: z.string(),
}),
advancedCreate: {
data: z.object({
name: z.string().min(3),
role: z.literal('user'),
}),
},
advancedUpdate: {
data: z.object({
role: z.enum(['manager', 'staff']),
}),
},
},
});Validation order:
- built-in route/query/body-shape validation runs first
- user-defined
requestSchemasvalidation runs second - write-operation model
validatehooks still run afterward in the service layer
Custom Route Validation
The package also exports the same validation helpers used by the built-in public routers:
parsePathParamparseQueryparseBodyrequestSchemas- advanced body schemas such as
listBodySchema,readByIdBodySchema,advancedCreateBodySchema, andadvancedUpdateBodySchema
Example:
import acl, {
parseBody,
parsePathParam,
parseQuery,
requestSchemas,
readByIdBodySchema,
} from '@web-ts-toolkit/access-router';
const router = acl.createRouter('User', {
basePath: '/users',
});
router.router.post('/custom/:id', async (req) => {
const id = parsePathParam(req.params.id, 'id');
const { include_permissions } = parseQuery(requestSchemas.readQuery, req.query);
const body = parseBody(readByIdBodySchema, req.body);
return {
id,
includePermissions: include_permissions === 'true',
body,
};
});These helpers throw the same BadRequestError shape as the built-in router endpoints, so custom routes can stay consistent with the package defaults.
Hook Signatures
The most common model hooks are called with this bound to the current Express request.
baseFilter
(this: express.Request, permissions: Permissions) =>
| Filter
| true
| null
| undefined
| Promise<Filter | true | null | undefined>- Return a
Filterto restrict access. - Return
true,null, orundefinedfor no extra base filter. - Return
falseto deny access.
decorate
(this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
unknown | Promise<unknown>;- Runs after a document has been loaded and trimmed.
- Can also be an array of middleware functions.
overrideFilter
(this: express.Request, filter: Filter, permissions: Permissions) => Filter | Promise<Filter>;- Runs before the base filter is applied.
- Use it to rewrite or augment the caller-provided filter.
validate
(this: express.Request, allowedData: unknown, permissions: Permissions, context: MiddlewareContext) => boolean | unknown[] | Promise<boolean | unknown[]>- Return
trueto allow the write. - Return
falseto reject it. - Return an array to provide validation errors.
prepare
(this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
unknown | Promise<unknown>;- Runs before create/update data is assigned to the document.
- Can also be an array of middleware functions.
transform
(this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
unknown | Promise<unknown>;- Runs during update flows before the document is saved.
- Can also be an array of middleware functions.
finalize
(this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
unknown | Promise<unknown>;- Runs after create/update persistence work and before response decoration.
- Can also be an array of middleware functions.
docPermissions
(this: express.Request, doc: unknown, permissions: Permissions, context: MiddlewareContext) =>
Record<string, unknown> | Promise<Record<string, unknown>>;- Returns the document-level permission object written to the configured permission field.
Example
acl.createModelRouter('Post', {
baseFilter: {
read(this, permissions) {
if (permissions.has('isAdmin')) return true;
return { published: true };
},
},
validate: {
create(this, data) {
if (!data || typeof data !== 'object' || !('title' in data)) {
return ['title is required'];
}
return true;
},
},
prepare: {
create(this, data) {
if (typeof data === 'object' && data) {
return { ...data, createdAt: new Date() };
}
return data;
},
},
transform: {
update(this, doc) {
return doc;
},
},
finalize: {
update(this, doc) {
return doc;
},
},
decorate: {
read(this, doc) {
const record = typeof doc === 'object' && doc ? doc : {};
return { ...record, summary: '...' };
},
},
overrideFilter: {
read(this, filter) {
return filter ?? {};
},
},
docPermissions: {
read(this, doc, permissions) {
return {
canArchive: permissions.has('isAdmin'),
};
},
},
});