Framework-agnostic permission/role engine for Vela: defineRole, fail-closed can(), composition + masking helpers. Zero runtime dependencies, edge-runtime safe (no node:*, no Buffer, no process).
Authorization is one decision — "is this identity allowed to do this?" — that has to be answered identically across HTTP, WebSocket, live queries, and background jobs. This package is that single answer. It resolves an Identity to a set of granted permissions and decides can() fail-closed: absent a session, the anonymous zero-privilege identity is used and nothing is granted.
The core is a plain function library — no decorators, no container, no framework coupling. Optional Vela integration lives behind a subpath.
pnpm add @velajs/authzimport { createAuthz, defineRole } from '@velajs/authz';
const authz = createAuthz({
roles: [
defineRole('editor', ['posts:read', 'posts:write']),
defineRole('admin', ['*']),
],
});
await authz.can({ roles: ['editor'] }, 'posts:write'); // true
await authz.can({ roles: ['editor'] }, 'posts:delete'); // false
await authz.can({ roles: ['admin'] }, 'anything:at:all'); // true (wildcard)Identity— who is asking. All fields optional, so{}andanonymousare valid zero-privilege identities.defineRole(name, permissions)/definePermission(name)— declare the role→permission table.defineRolecopies the permissions array, so the returnedRoleDefnever aliases the caller's input.createAuthz(options)— builds anAuthzover a role table (or a customresolver). Returns{ can, resolver }. If you pass apermissionsallow-list,createAuthzthrows when a role grants an undeclared (non-wildcard) permission — a build-time guard against typos.can(identity, permission, resolver)— the standalone fail-closed check;authz.can(identity, permission)is the same check bound to the built resolver.
interface Identity {
issuer?: string;
subject?: string;
principalType?: 'user' | 'service';
/** @deprecated compatibility alias for subject */
userId?: string;
roles?: string[];
claims?: Record<string, unknown>;
}
interface PermissionResolver {
grants(identity: Identity): Set<string> | Promise<Set<string>>;
}Authenticated adapters should populate { issuer, subject, principalType }. Treat the (issuer, subject) pair as the durable principal key: OIDC subjects are issuer-local and can collide across identity providers. userId remains as a compatibility alias while applications migrate.
anonymous is the zero-privilege identity ({ roles: [] }, frozen) — the fail-closed default when no session is present.
A granted permission string matches the requested permission when:
- it is
*— grants everything; - it equals the requested permission exactly (e.g.
posts:write); - it is
resource:*— grants any action under that resource (e.g.posts:*grantsposts:delete).
Wildcards live on the granted side (what a role holds), not the requested side.
Authorization defaults to deny. Every ambiguous or broken path denies rather than leaks:
- No session → use
anonymous→ grants nothing. - Unknown role / missing permission → denied.
- A resolver that throws → denied (there is no allow-on-error path).
anyOf()with no policies → denied (nothing grants access).- A policy that throws inside
anyOf/allOf→ that branch is denied; it can never allow. maskwhose transform throws → redacts tonull, never leaks the raw value.
(allOf() with no policies is vacuously true — an empty AND — but an empty OR denies.)
Policy is a plain predicate over a context and a resource:
type Policy<C = { identity: Identity }, R = unknown> =
(ctx: C, resource: R) => boolean | Promise<boolean>;Combine capability checks with resource-level rules (ownership, tenancy, state):
anyOf(...policies)— OR, read semantics. Any policy granting → allowed. Empty → denied.allOf(...policies)— AND, write semantics. All must allow.hasPerm(authz, permission)— bridge a capability check into aPolicythat reads onlyctx.identity.mask(fn)— wrap a field transform so a throw redacts tonullinstead of leaking.
import { anyOf, allOf, hasPerm, mask } from '@velajs/authz';
const isOwner = (ctx: { identity: { userId?: string } }, post: { authorId: string }) =>
ctx.identity.userId === post.authorId;
// A reader may see a post if they own it OR hold posts:read.
const canRead = anyOf(isOwner, hasPerm(authz, 'posts:read'));
// A writer must own it AND hold posts:write.
const canWrite = allOf(isOwner, hasPerm(authz, 'posts:write'));
await canRead({ identity: { userId: 'u1', roles: [] } }, { authorId: 'u1' }); // true
// Redact a sensitive field, fail-closed to null on any error.
const lastFour = mask((_ctx: unknown, r: { ssn: string }) => r.ssn.slice(-4));
lastFour({}, { ssn: '123456789' }); // '6789'Optional. @velajs/authz/vela wires the engine into a Vela app. @velajs/vela is an optional peer dependency — the core engine has no framework coupling and runs anywhere (edge, Node, Workers, Deno, Bun).
MIT © Kauan Guesser