NestJS and Node
Wire zynactra/access into a NestJS (or any Node) service with Drizzle or pg: startup, the module, controllers, lists and errors.
Packages: @zynactra/access (core), @zynactra/access-drizzle (Postgres store, migrations, NOTIFY listener, drizzleFilter) and @zynactra/access-nest (module, guard, decorators, the access API). ESM, Node 22 or later. examples/nest-app is the complete service this page quotes.
Startup
import { Access, AdminService, CerbosHTTP, parseManifest } from "@zynactra/access";
import { listen, migrate, PgStore } from "@zynactra/access-drizzle";
import { manifest as compiled } from "./access/generated.js"; // zaccess gen (out.ts)
const manifest = parseManifest(compiled);
await migrate(pool); // the access schema; with Drizzle: db.$client
const store = new PgStore(pool);
await store.syncCatalog(manifest); // catalog + system roles
const access = new Access({
manifest,
cerbos: new CerbosHTTP(process.env.CERBOS_URL!), // the sidecar, http://127.0.0.1:3592
store,
resolver, // principalAttrs and expandSet from your tables
strict: isDevOrTest,
});
const admin = new AdminService(access, store);
const stop = listen(process.env.DATABASE_URL!, access); // keep every instance's cache fresh./access/generated.js exports the P constants and the compiled manifest. Other options are in Configuration.
The module
import { AccessModule } from "@zynactra/access-nest";
@Module({
imports: [
AccessModule.forRoot({
access,
admin, // serves the access API under /access
subject: (req) => {
const user = (req as { user?: Claims }).user; // set by your auth (passport, a guard)
return user && { userId: user.sub, orgId: user.org, idp: user.iss, groups: user.groups };
},
loaders: { "leave.request": loadLeaveResources }, // for /access/check and /access/explain
strict: isDevOrTest,
}),
],
controllers: [LeaveController],
})
export class AppModule {}AccessModule registers a global guard, an interceptor for strict requirement tracking and an exception filter that renders access errors as RFC 9457 problems. Your authentication must run before the guard reads subject(req).
By default a route with no access decorator is denied (denyUndeclared). Mark health checks and login with @AccessPublic(). AccessModule.forRootAsync({ useFactory, inject, path }) builds the options from other providers.
Controllers
import { ACCESS, CurrentSubject, RequirePermission } from "@zynactra/access-nest";
import { P } from "../access/generated.js";
@Controller("leave-requests")
export class LeaveController {
constructor(@Inject(ACCESS) private readonly access: Access, @Inject(DB) private readonly db: NodePgDatabase) {}
@Post(":id/approve")
@HttpCode(200)
@RequirePermission(P.LEAVE_REQUEST_APPROVE) // guard: holds it somewhere?
async approve(@CurrentSubject() s: Subject, @Param("id") id: string) {
const row = await this.load(s, id); // throws not_found
await this.access.assert(s, P.LEAVE_REQUEST_APPROVE, leaveResource(row)); // the record check
// update ... where id = $1 and status = 'pending'
}
}@RequirePermission(P.X)declares the route's permission.@RequireCapability(P.X)is for routes with no record.@CurrentSubject()injects the subject (401 when there is none).- Throw
new AccessError({ code: "not_found", message })for errors you want rendered as problems; the filter maps codes to statuses. - Strict mode: a
@RequirePermissionhandler that succeeds withoutassert,check,checkManyorplanon that permission fails with 500unchecked_requirement. Don't use@Res()on guarded routes: strict mode needs Nest to send the response.
The resource
One function per kind:
export function leaveResource(r: LeaveRow): Resource {
return {
kind: "leave.request",
id: r.id,
attr: { orgId: r.orgId, ownerId: r.ownerId, ownerPath: r.ownerPath, orgUnitId: r.orgUnitId, status: r.status, days: Number(r.days) },
};
}numeric columns arrive as strings from pg: convert them, or checks are denied as invalid_resource.
Lists
With Drizzle:
import { drizzleFilter } from "@zynactra/access-drizzle";
@Get()
@RequirePermission(P.LEAVE_REQUEST_READ)
async list(@CurrentSubject() s: Subject) {
const plan = await this.access.plan(s, P.LEAVE_REQUEST_READ);
return this.db.select().from(leaveRequests).where(
and(
eq(leaveRequests.orgId, s.orgId),
drizzleFilter(plan, {
orgId: leaveRequests.orgId, ownerId: leaveRequests.ownerId, ownerPath: leaveRequests.ownerPath,
orgUnitId: leaveRequests.orgUnitId, status: leaveRequests.status, days: leaveRequests.days,
}),
),
).orderBy(desc(leaveRequests.createdAt)).limit(50);
}Without Drizzle, planToSql(plan, { ownerId: "lr.owner_id", ... }, { startAt: 2 }) from @zynactra/access returns { sql, params } for any pg-style client, or ? placeholders with placeholder: "question". The SQL is identical to Go's sqlfilter. See List filters.
Loaders for the access API
const loadLeaveResources: Loader = async (s, ids) => {
const rows = await db.select().from(leaveRequests)
.where(and(eq(leaveRequests.orgId, s.orgId), inArray(sql`${leaveRequests.id}::text`, ids)));
return Object.fromEntries(rows.map((r) => [r.id, leaveResource(r)]));
};The server loads records itself for /access/check and /access/explain; the UI never sends attributes. IDs a loader doesn't return come back allowed: false.
Without Nest
The core calls are the same in any Node framework: access.hasPermission for the guard, assert / check / checkMany / plan in handlers. Render errors as problems with your own mapping from err.code (see Errors), or toProblem(err) from @zynactra/access-nest if Nest is installed anyway. withRequirement(permission, fn) from @zynactra/access gives you strict requirement tracking around a handler.
Admin from code
import { bootstrapAdmin } from "@zynactra/access";
await bootstrapAdmin(access, store, orgId, firstAdminUserId); // once per org
await admin.createAssignment(svcOnboarding, { userId, roleId: employeeRoleId, scopeType: "self" });The admin service applies every rule to every caller, service accounts included.