@web-ts-toolkit/access-router
Access-policy Express routers and in-memory data services for Mongoose-backed APIs.
Installation
- npm
- Yarn
- pnpm
- Bun
npm install @web-ts-toolkit/access-router express mongoose
yarn add @web-ts-toolkit/access-router express mongoose
pnpm add @web-ts-toolkit/access-router express mongoose
bun add @web-ts-toolkit/access-router express mongoose
Quick Start
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 }],
idField: 'id',
operationAccess: {
list: true,
read: true,
},
permissionSchema: {
id: true,
name: 'isAdmin',
public: true,
},
});
TypeScript Support
Typed routers carry the model or data shape through filters, selects, defaults, and service accessors.
Typed model routers
import mongoose from 'mongoose';
import acl from '@web-ts-toolkit/access-router';
type User = {
name: string;
role: string;
profile: {
city: string;
};
public: boolean;
};
const UserModel = mongoose.model<User>(
'User',
new mongoose.Schema({
name: String,
role: String,
profile: {
city: String,
},
public: Boolean,
}),
);
const userRouter = acl.createRouter(UserModel, {
basePath: '/users',
baseFilter: {
list(permissions) {
return permissions.isAdmin ? {} : { public: true };
},
},
defaults: {
findOptions: {
sort: { name: 1 },
select: ['name', 'profile.city'],
},
},
});
const service = userRouter.getService(req);
await service.find({
filter: { 'profile.city': 'Berlin' },
select: ['name', 'profile.city'],
});
Typed data routers
import acl from '@web-ts-toolkit/access-router';
type Fruit = {
id: string;
name: string;
stock: number;
public: boolean;
};
const fruitRouter = acl.createDataRouter<Fruit>('fruit', {
basePath: '/fruit',
idField: 'id',
data: [{ id: 'apple', name: 'Apple', stock: 12, public: true }],
});
const service = fruitRouter.getService(req);
const result = await service.findById({
id: 'apple',
select: ['id', 'name'],
});
result.name;
Typed filters
Filter<T> and DataFilter<T> support dotted paths for nested fields.
const service = userRouter.getService(req);
await service.find({
filter: {
role: { $in: ['admin', 'editor'] },
'profile.city': 'Berlin',
},
});
When no model or data type is known, filters still fall back to a loose Record<string, unknown> shape.
Typed select and populate
Public read and list methods narrow their return types when select is positive and simple enough to model.
const service = userRouter.getPublicService(req);
const users = await service.find({
select: ['name', 'profile.city'],
populate: [{ path: 'manager', select: ['name'] }],
});
Typing is intentionally conservative:
- positive
selectnarrows returned fields - exclusion-only or complex select shapes fall back to the broader public output type
- populate merges selected nested paths, but does not infer foreign model types from runtime refs
Typed defaults
const router = acl.createRouter(UserModel, {
defaults: {
findOptions: {
sort: { name: 1 },
filter: { public: true },
},
findByIdOptions: {
select: ['name', 'role'],
},
},
});
Request and permission augmentation
The package exposes augmentable interfaces so hooks can use custom request fields and permission names without manual annotations.
import '@web-ts-toolkit/access-router';
declare module '@web-ts-toolkit/access-router' {
interface AccessRouterPermissionMap {
isAdmin?: boolean;
}
interface AccessRouterRequestExtensions {
requestId?: string;
}
}
After augmentation, hooks and global permission handlers see those fields automatically:
acl.setGlobalOptions({
globalPermissions(req) {
req.requestId = String(req.headers['x-request-id'] ?? 'request');
return req.headers.user === 'admin' ? ['isAdmin'] : [];
},
});
acl.createDataRouter('fruit', {
decorate(item, permissions) {
if (permissions.isAdmin && this.requestId) {
return { ...item, requestId: this.requestId };
}
return item;
},
});
List Responses
List endpoints 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
When include_count=true is also enabled:
wtt-total-countwtt-total-pageswtt-has-next-page
Request Validation
Public router endpoints 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',
idField: '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.
Lifecycle Phases
Model router hooks are grouped by lifecycle phase:
- access and query shaping:
operationAccess,overrideFilter,baseFilter - pre-write:
validate,prepare,transform - post-persist:
afterPersist - update diff side effects:
onChange - delete lifecycle:
beforeDelete,afterDelete - response shaping:
docPermissions,decorate,decorateAll
Effective model-router flows:
- create:
operationAccess -> validate -> prepare -> create -> afterPersist -> docPermissions -> decorate - update:
operationAccess -> overrideFilter/baseFilter -> validate -> prepare -> transform -> save -> afterPersist -> onChange -> docPermissions -> decorate - delete:
operationAccess -> overrideFilter/baseFilter -> beforeDelete -> delete -> afterDelete - read/list:
operationAccess -> overrideFilter/baseFilter -> docPermissions -> decorate -> decorateAll
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 hook 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 or update data is assigned to the document.
- Can also be an array of hook 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 hook functions.
afterPersist
(this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
unknown | Promise<unknown>;
- Runs after create or update persistence work and before response decoration.
- Can also be an array of hook functions.
beforeDelete
(this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
void | Promise<void>;
- Runs after the target document has been loaded and authorized, before deletion.
- Use it for last-minute checks or side effects that need the live document.
afterDelete
(this: express.Request, value: unknown, permissions: Permissions, context: MiddlewareContext) =>
void | Promise<void>;
- Runs after deletion succeeds.
- Use it for audit logs, cache invalidation, and external notifications.
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;
},
},
afterPersist: {
update(this, doc) {
return doc;
},
},
afterDelete(this, doc) {
console.log('deleted', 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'),
};
},
},
});