typescript-permission
v0.1.0-beta.1
Published
A framework-agnostic TypeScript RBAC / role & permission library with Next.js and React integrations — single package, subpath exports, interactive setup.
Maintainers
Readme
typescript-permission
A framework-agnostic TypeScript RBAC + ABAC library — Roles, Permissions, Wildcards, Teams, Multiple Guards, Super-Admin, Cache and Events, plus attribute-based conditions, field-level filtering, deny-overrides, model policies and custom permission checks. Run it in any TypeScript runtime (Next.js, NestJS, Express, Hono, React…); first-class Next.js & React integrations included.
📖 Documentation: English · Türkçe — plain Markdown, readable directly on GitHub.
✨ Features
Beyond RBAC (attribute-based & policy):
- Conditional permissions / ABAC — resource-scoped rules with
ownedBy,ctx,$eq/$lt/$in/… operators; fail-closed evaluation → docs - Field-level filtering —
permittedFields/filterFieldswithfields: { deny: [...] }/fields: { allow: [...] } - Deny-overrides —
effect: "deny"wins over any allow across all roles; fail-closed - Model policies —
before+ async policy functions (ownership + permission + super-admin) → docs - Custom permission checks —
beforehooks withregisterPermissionCheckMethod: falsefor JWT scopes / external claims → docs
Core RBAC:
- Roles, permissions, wildcards (
posts.*,*.create), teams (multi-tenant), multiple guards, super-admin gate
Performance & observability:
- Cache (TTL + flush triggers), events (
RoleAttached/Detached,PermissionChecked…) - Snapshot (server → client) → docs
- Exception display config → docs
Framework integrations:
- Next.js middleware + route guards, React components (
<Can>,<Role>, …), NextAuth v5 adapter
DX:
- Single package + subpath exports, interactive CLI (
init), Prisma & Drizzle adapters, extensiblePermissionStore→ docs
📦 Packages
The monorepo is an ORM/auth-agnostic core surrounded by thin adapters:
| Package | Description | Test Files |
|---------|-------------|:----------:|
| @typescript-permission/core | ORM/auth-agnostic RBAC + ABAC engine: PermissionService, Gate, Wildcard, Cache, Events, Error types | 35 |
| @typescript-permission/prisma | Prisma/SQLite adapter — PermissionStore implementation | 11 |
| @typescript-permission/drizzle | Drizzle ORM adapter — PermissionStore for PostgreSQL, MySQL and SQLite | 5 |
| @typescript-permission/next | Next.js middleware, route guards (authorize*), request-scoped team context | 5 |
| @typescript-permission/react | <Can>, <Role>, <CanAny>, <HasAnyRole>, <HasAllRoles> components + useCan, useHasRole hooks | 1 |
| @typescript-permission/nextauth | NextAuth/Auth.js v5 adapter — session → model, JWT/session augment | 7 |
| @typescript-permission/cli | CLI commands: create-role, create-permission, assign-role, cache-reset, show | 9 |
The packages above are internal modules of the monorepo. You install a single package into your app: the
typescript-permissionpackage below exposes them all through subpath exports.
📥 Installation (npm)
A single package — every module ships behind subpaths:
npm i typescript-permission
# or: pnpm add / yarn add / bun addThen run the interactive setup wizard — a modern arrow-key flow (@clack/prompts) that asks 3 questions (adapter / auth / teams, each pre-selected from a smart default detected in your package.json; React is auto-detected, not asked), installs the required peer packages, and generates permission.config.ts:
npx typescript-permission init
# Automatic (no prompts, in-memory): npx typescript-permission init --yesPick Prisma or Drizzle and
initdoes the rest. With Prisma 7+ and an existingprisma/schema.prismait writes the 5 RBAC models asprisma/rbac.prisma(multi-file schema: one client, one migration history; setschema: "prisma"inprisma.config.ts). Otherwise — Prisma ≤6, no schema yet, or--layout standalone— it writes a separate, self-contained schema (prisma-rbac/ordrizzle-rbac/). Tables are pushed automatically whenDATABASE_URLis set; otherwise the exact command is printed.--no-pushgenerates files only.Multi-tenant: shared DB →
--tenant-model Tenant(requires Teams; implied with--yes) addsRole.teamId → Tenant.id(cascade; you addroles Role[]toTenant). Database-per-tenant →--tenancy databaseemitsgetPermissionServiceFor(client)(one service per tenant client) and no FK. Step-by-step: see the Installation guide (TR: kurulum rehberi).
Subpath import map
| Import | Contents | Required peer (optional) |
|--------|----------|--------------------------|
| typescript-permission | Core: PermissionService, resolveConfig, Gate, Wildcard, Cache, Events, Error types, ownedBy/ctx helpers | — (only zod) |
| typescript-permission/testing | InMemoryPermissionStore (non-persistent; quick start/tests) | — |
| typescript-permission/react | <Can>, <Role>, useCan … (client) | react |
| typescript-permission/react/server | Server-side snapshot helpers | react |
| typescript-permission/next | Middleware, route guards, team context | next |
| typescript-permission/nextauth | NextAuth/Auth.js v5 adapter | next-auth |
| typescript-permission/prisma | PrismaPermissionStore | @prisma/client |
| typescript-permission/drizzle (/sqlite, /pg, /mysql) | DrizzlePermissionStore | drizzle-orm |
All heavy dependencies are optional peerDependencies —
npm i typescript-permissiononly pulls inzod. You either install the peer of the adapter you need (e.g.@prisma/client) yourself, or letinitinstall it for you.
CLI commands (permission or the typescript-permission binary): init, create-role, create-permission, assign-role, cache-reset, show.
⚡ Quick start
import { PermissionService, resolveConfig } from "typescript-permission";
import { InMemoryPermissionStore } from "typescript-permission/testing";
const service = new PermissionService(new InMemoryPermissionStore(), resolveConfig());
const alice = { modelType: "User", modelId: 1 };
await service.createRole("editor", "web");
await service.createPermission("posts.edit", "web");
await service.attachPermissionToRole("editor", "posts.edit");
await service.assignRole(alice, "editor");
await service.can(alice, "posts.edit"); // => true (inherited via the role)
await service.hasRole(alice, "editor"); // => true🎯 Feature spotlights
Conditional permissions (ABAC)
Declare the resource schema, grant a conditional rule with the ownedBy shortcut, and check against a subject:
import { PermissionService, resolveConfig, ownedBy, ctx } from "typescript-permission";
const svc = new PermissionService(
store,
resolveConfig({ resources: { "work-order": { assignedToId: "id", amount: "number" } } }),
);
// Technician can update a work order only if it is assigned to them.
await svc.grantToRole("Technician", "work-order.update", {
conditions: ownedBy("assignedToId"), // → { assignedToId: { $eq: { $ctx: "actor.modelId" } } }
});
// Team-scoped rule using ctx():
await svc.grantToRole("Technician", "work-order.view", {
conditions: { teamId: { $eq: ctx("team.id") } },
});
await svc.can(user, "work-order.update", { subject: workOrder }); // true/false per record
// Diagnose a "false" — does the grant require a subject?
const e = await svc.explain(user, "work-order.update");
e.requiresSubject; // true → a conditional grant exists but no subject was givenFail-closed: if the condition cannot be evaluated (no subject), allow is not counted and deny applies. → full docs: conditional-permissions.md
Field-level filtering
Hide sensitive fields per role via a deny grant, then read the allowed set or filter a payload:
// Service advisors can view parts, but the cost field is hidden.
await svc.grantToRole("Service-Advisor", "part.view", {
effect: "deny",
fields: { deny: ["cost"] },
});
const set = await svc.permittedFields(user, "part.view"); // FieldSet
const safe = await svc.filterFields(user, "part.view", part); // Partial<Part>
filterFieldsis a serialization utility, not a security boundary — the data is already in memory; filtering it before putting it in the response is your job.
→ full docs: conditional-permissions.md
Deny-overrides
A deny grant wins over any allow across all roles and is fail-closed:
// Intern can never delete invoices, regardless of any allow grant they also hold.
await svc.grantToRole("Intern", "invoice.delete", { effect: "deny" });
await svc.can(intern, "invoice.delete"); // false, even if another role allows iteffect: "deny" with fields.deny: [...] does not deny the permission — it only hides those fields.
→ full docs: conditional-permissions.md
Model policies
Plain async functions combining PermissionService + the before hook reproduce Laravel-style Policy classes — combining permission check, ownership rule and super-admin bypass:
svc.before(async (model, _ability, guard) =>
(await svc.hasGlobalRole(model, "Super Admin", guard)) ? true : null,
);
async function postPolicy(user, ability, post?) {
if (await svc.hasRole(user, "Super Admin")) return true; // 1. super-admin
if (await svc.can(user, ability)) return true; // 2. permission via the gate
if (ability === "update" && post && post.authorId === user.modelId) return true; // 3. ownership
return false;
}→ full docs: model-policies.md
Custom permission checks
Disable the default DB check and authorize purely through before hooks — suitable for JWT scopes or external claim sources:
const svc = new PermissionService(
store,
resolveConfig({ registerPermissionCheckMethod: false }),
);
svc.before((model, ability) => {
const scopes = tokenScopes.get(model.modelId as number);
if (!scopes) return null; // no scopes → don't decide
return scopes.has(String(ability)) ? true : null; // has scope → allow, else pass
});
await svc.can(user1, "read:posts"); // true
await svc.can(user1, "delete:posts"); // false (no scope, no DB fallback)Multiple hooks run in registration order; the first boolean wins, null/undefined passes through.
→ full docs: custom-permission-check.md
Snapshot (server → client)
Resolve permissions on the server and send only boolean results to the client. With conditional: true, a conditional allow grant surfaces as "conditional" instead of being silently denied:
import { buildSnapshot } from "typescript-permission";
// Server Component
const snap = await buildSnapshot(svc, model, {
permissions: ["invoice.apply-discount", "posts.edit"],
conditional: true, // → PermissionState = boolean | "conditional"
});
// Client Component
<PermissionProvider value={snap}>
<Can
permission="invoice.apply-discount"
whenConditional={<button>Apply discount (pending approval)</button>}
fallback={<p>You do not have discount permission.</p>}
>
<button>Apply discount</button>
</Can>
</PermissionProvider>"conditional" is not a permission — it means "maybe"; the route handler makes the definitive decision against the real subject. Default (conditional: false/omitted) is fail-closed and byte-identical to the legacy behavior. Never test the raw value for truthiness ("conditional" is truthy); use === true.
→ full docs: snapshot.md · conditional UI
Exception display config
UnauthorizedException factories accept a display flag (default true) controlling whether role/permission names appear in the message — meta fields are always populated:
import { UnauthorizedException } from "typescript-permission";
// Hide names in production; show in development.
const err = UnauthorizedException.forPermissions(
["posts.edit"],
process.env.NODE_ENV !== "production",
);
err.requiredPermissions; // ["posts.edit"] — always populated, for logging→ full docs: exception-display-config.md
Extending
PermissionService talks to data access only through the PermissionStore interface — implement it (or wrap an existing store) to add logging, metrics, distributed cache, or a different ORM:
class LoggingStore extends InMemoryPermissionStore {
public logs: string[] = [];
async assignRole(model, roleId, teamId?) {
this.logs.push(`assignRole(${model.modelId}, ${roleId})`);
return super.assignRole(model, roleId, teamId);
}
}
const svc = new PermissionService(new LoggingStore());→ full docs: extending.md
📖 Core RBAC reference
RBAC Core Concepts
USER ──assignRole()──▶ ROLE ──attachPermissionToRole()──▶ PERMISSIONS
👤 "Moderator" posts.edit, posts.delete
can("posts.edit") → true (inherited via the role)
hasPermissionTo("posts.edit") → true
hasRole("Moderator") → true📋 Role Management
| Method | Description | PHP Equivalent |
|--------|-------------|-----------------------|
| createRole(name, guard?, teamId?) | Create a new role | Role::create([...]) |
| findOrCreateRole(name, guard?, teamId?) | Find or create | Role::findOrCreate(...) |
| updateRole(role, { name?, guard?, teamId? }) | Update a role (rename/re-scope) | $role->update([...]) |
| deleteRole(role) | Delete a role (pivots cascade) | $role->delete() |
| assignRole(model, ...roles) | Assign a role to a user | $user->assignRole('writer') |
| removeRole(model, ...roles) | Remove a role from a user | $user->removeRole('writer') |
| syncRoles(model, ...roles) | Replace roles | $user->syncRoles(['writer']) |
| hasRole(model, roles, guard?) | Query a role | $user->hasRole('writer') |
| hasAnyRole(model, ...roles) | Has at least one? | $user->hasAnyRole(...) |
| hasAllRoles(model, roles) | Has all? | $user->hasAllRoles(...) |
| hasExactRoles(model, roles) | Exact match? | $user->hasExactRoles(...) |
| getRoleNames(model) | All roles | $user->getRoleNames() |
🔐 Permission Management
| Method | Description | PHP Equivalent |
|--------|-------------|-----------------------|
| createPermission(name, guard?) | Create a new permission | Permission::create([...]) |
| findOrCreatePermission(name, guard?) | Find or create | Permission::findOrCreate(...) |
| updatePermission(permission, { name?, guard? }) | Update a permission (rename) | $permission->update([...]) |
| deletePermission(permission) | Delete a permission | $permission->delete() |
| givePermissionTo(model, ...permissions) | Grant a permission to a user | $user->givePermissionTo('edit') |
| revokePermissionTo(model, ...permissions) | Revoke a permission | $user->revokePermissionTo('edit') |
| syncPermissions(model, ...permissions) | Replace permissions | $user->syncPermissions([...]) |
| hasPermissionTo(model, permission, guard?) | Has the permission? | $user->hasPermissionTo('edit') |
| hasDirectPermission(model, permission) | Direct permission? | $user->hasDirectPermission('edit') |
| hasAnyPermission(model, ...permissions) | At least one? | $user->hasAnyPermission([...]) |
| hasAllPermissions(model, ...permissions) | All? | $user->hasAllPermissions([...]) |
| checkPermissionTo(model, permission) | Does not throw | (custom) |
🔗 Role-Permission Binding
| Method | Description | PHP Equivalent |
|--------|-------------|-----------------------|
| attachPermissionToRole(role, permission) | Bind a permission to a role | $role->givePermissionTo('edit') |
| detachPermissionFromRole(role, permission) | Remove a permission from a role | $role->revokePermissionTo('edit') |
| syncPermissionsForRole(role, ...permissions) | Replace a role's permissions | $role->syncPermissions([...]) |
| roleHasPermission(role, permission) | Does the role have the permission? | $role->hasPermissionTo('edit') |
🚪 Gate API (Super Admin)
| Method | Description | PHP Equivalent |
|--------|-------------|-----------------------|
| can(model, ability) | Check via the gate | $user->can('edit') |
| canAny(model, abilities) | At least one? | $user->canAny([...]) |
| cannot(model, ability) | The inverse | $user->cannot('edit') |
| before(hook) | Register a gate hook | Gate::before(...) |
👥 Scoped API (Fluent Pattern)
// User-scoped (HasRoles/HasPermissions trait)
const u = service.forUser(model);
await u.assignRole("editor");
await u.givePermissionTo("posts.edit");
await u.can("posts.edit"); // true (via the gate)
// Role-scoped (Role model)
const r = service.forRole("editor");
await r.givePermissionTo("posts.edit");
await r.hasPermissionTo("posts.edit"); // true🎭 Super Admin (Gate::before)
svc.before(async (model, _ability, guard) =>
(await svc.hasGlobalRole(model, "Super Admin", guard)) ? true : null
);
await svc.can(admin, "anything.at.all"); // true (Super Admin)
await svc.hasPermissionTo(admin, "anything"); // false (skips the gate)Important: hasPermissionTo() and hasRole() do not go through the gate. The Super Admin bypass only applies to can(), canAny(), cannot().
Use hasGlobalRole, not hasRole. hasRole matches on the role name only and ignores teams. With teams: true, a tenant who can manage roles inside their own team could create a role named "Super Admin", assign it to themselves, and clear every permission the platform never granted. hasGlobalRole matches only roles with teamId === null. With teams disabled the two are equivalent, so this costs nothing.
Pass the guard through. Without it, roles across all guards are scanned; a "Super Admin" assigned in a low-trust guard would unlock a high-trust one (cross-guard escalation).
Apply the same condition outside the gate. Route guards that check Super Admin themselves (requireSuperAdmin()-style helpers) bypass the gate entirely — they must use hasGlobalRole too, or the fix in the hook is undone at the route.
🃏 Wildcard Permissions
const config = resolveConfig({ enableWildcardPermission: true });
const service = new PermissionService(store, config);
await service.createPermission("posts.*", "web");
await service.givePermissionTo(model, "posts.*");
await service.hasPermissionTo(model, "posts.create"); // true
await service.hasPermissionTo(model, "posts.delete.soft"); // true (deep)
await service.hasPermissionTo(model, "comments.create"); // false| Pattern | Meaning | Matches |
|---------|---------|---------|
| posts.* | All post actions | posts.create, posts.edit, posts.delete |
| * | All permissions | Everything |
| *.create | create on any resource | posts.create, users.create |
| posts.create,update | Comma-separated sub-parts | posts.create, posts.update |
👥 Teams (Multi-Tenant)
const config = resolveConfig({ teams: true });
const service = new PermissionService(store, config);
service.setPermissionsTeamId(5);
await service.assignRole(model, "admin");
service.setPermissionsTeamId(9);
await service.hasRole(model, "admin"); // false (different team)
// A global role is visible in every team:
service.setPermissionsTeamId(null);
await service.assignRole(model, "global-role");
service.setPermissionsTeamId(5);
await service.hasRole(model, "global-role"); // true🔀 Multiple Guards
const webModel = { modelType: "User", modelId: 1 }; // default: "web"
const apiModel = { modelType: "User", modelId: 2, guardName: "api" };
await service.hasPermissionTo(webModel, "edit"); // web/edit
await service.hasPermissionTo(webModel, "edit", "api"); // false
await service.hasPermissionTo(apiModel, "edit"); // api/edit💾 Cache Mechanism
const cache = new InMemoryPermissionCache(3600); // TTL: 1 hour
const service = new PermissionService(store, undefined, undefined, cache);
await service.forgetCachedPermissions(); // manual flushCache flush triggers:
| Operation | Cache Flush? |
|-----------|:------------:|
| createRole(), createPermission() | ✅ Yes |
| updateRole(), updatePermission() | ✅ Yes |
| deleteRole(), deletePermission() | ✅ Yes |
| attachPermissionToRole(), detachPermissionFromRole() | ✅ Yes |
| syncPermissionsForRole() | ✅ Yes |
| assignRole() (user), removeRole() (user) | ❌ No |
| givePermissionTo() (user), revokePermissionTo() (user) | ❌ No |
| forgetCachedPermissions() | ✅ Manual |
📸 Snapshot
Snapshot mechanics (server → client, wildcards/gate hooks resolved server-side) are covered in the Snapshot spotlight above and in docs/en/framework/snapshot.md.
🔌 React Components (Blade → React)
| Blade | React Component | React Hook |
|-------|-----------------|------------|
| @can('posts.create') | <Can permission="posts.create"> | useCan('posts.create') |
| @canany(['a', 'b']) | <CanAny permissions={[...]}> | useCanAny([...]) |
| @role('admin') | <Role role="admin"> | useHasRole('admin') |
| @hasanyrole('a\|b') | <HasAnyRole roles={[...]}> | useHasAnyRole([...]) |
| @hasallroles('a\|b') | <HasAllRoles roles={[...]}> | useHasAllRoles([...]) |
| @hasexactroles('a\|b') | <HasExactRoles roles={[...]}> | useHasExactRoles([...]) |
| @unlessrole('admin') | <UnlessRole role="admin"> | useUnlessRole('admin') |
⚠️ Exception Types
| Exception | When? | statusCode |
|-----------|-------|:----------:|
| PermissionDoesNotExist | Query for a non-existent permission | 500 |
| RoleDoesNotExist | Assigning a non-existent role | 500 |
| GuardDoesNotMatch | Cross-guard assignment | 500 |
| UnauthorizedException | Unauthorized access | 403 |
| TeamsNotEnabled | Team operation while teams are off | 500 |
| TeamModelNotConfigured | Team model not configured | 500 |
| WildcardPermissionInvalidArgument | Invalid wildcard | 500 |
| WildcardPermissionNotProperlyFormatted | Wildcard with empty parts | 500 |
Basic usage
1️⃣ Create Permissions
await service.createPermission("posts.edit", "web");
await service.createPermission("reports.*", "web"); // wildcard2️⃣ Create Roles
await service.createRole("Moderator", "web", null); // global
await service.createRole("Team Editor", "web", "team-a"); // team-scoped3️⃣ Bind Permissions to a Role
// Equivalent of $role->givePermissionTo():
await service.attachPermissionToRole("Moderator", "posts.edit");
await service.attachPermissionToRole("Moderator", "posts.delete");
// Removing:
await service.detachPermissionFromRole("Moderator", "posts.delete");4️⃣ Assign a Role to a User
await service.assignRole({ modelType: "User", modelId: "u-ahmet" }, "Moderator");
// In a team context:
await runWithTeam("team-a", () =>
service.assignRole({ modelType: "User", modelId: "u-ahmet" }, "Team Editor")
);5️⃣ Test Authorization
await service.can(model, "posts.edit"); // true (via the Moderator role)
await service.can(model, "posts.create"); // false (Moderator lacks this permission)
await service.hasRole(model, "Moderator"); // true6️⃣ Update or Delete a Role/Permission
// Rename / re-scope (cache is invalidated automatically):
await service.updateRole("Moderator", { name: "Mod", teamId: "team-a" });
await service.updatePermission("posts.edit", { name: "posts.update" });
// Delete (pivots cascade away):
await service.deleteRole("Mod");
await service.deletePermission("posts.update");🚀 Example app
A full Next.js example app lives in examples/nextjs-app — an AppShell-based RBAC admin panel, a usage kit with live API calls, and a feature dashboard. Run it:
pnpm install
cd examples/nextjs-app
cp .env.example .env
pnpm db:push && pnpm db:seed
pnpm devDemo users (password: secret):
| 👤 User | 🏷️ Role | 📁 Team | 🔑 Permissions |
|---------|---------|---------|----------------|
| [email protected] | Super Admin | 🌐 Global | All permissions (Gate::before) |
| [email protected] | Editor | 📁 Team A | posts.* wildcard |
| [email protected] | Viewer | 📁 Team B | Read-only |
| [email protected] | Editor | 📁 Team A | posts.* wildcard |
| [email protected] | Viewer | 📁 Team B | Read-only |
| [email protected] | — | 🌐 Global | No role assigned |
Pages:
/admin— Super Admin management panel: user/role/permission CRUD, role-permission matrix, quick-action seed buttons, all flows in modals./usage-kit— comprehensive usage kit: every scenario (M1–M20) with live API calls, PHP ↔ TypeScript comparison tables, and React directive demos./features— feature dashboard: real-time authorization status, switch between demo users.
📁 Project Structure
typescript-permission/
├── packages/
│ ├── core/ # Core RBAC + ABAC engine (35 test files, 335 tests)
│ │ └── src/
│ │ ├── service.ts # PermissionService (main class)
│ │ ├── store.ts # PermissionStore interface
│ │ ├── gate.ts # Gate::before hook types
│ │ ├── wildcard.ts # Wildcard engine
│ │ ├── cache.ts # Cache interface + InMemoryCache
│ │ ├── events.ts # Event dispatcher
│ │ ├── config.ts # Config + Zod schema
│ │ ├── errors.ts # All exception types
│ │ ├── scope.ts # UserScope + RoleScope
│ │ ├── snapshot.ts # buildSnapshot
│ │ ├── conditions/ # ABAC: condition language + builders (ownedBy, ctx)
│ │ └── testing/in-memory-store.ts
│ ├── drizzle-adapter/ # Drizzle ORM Store (PG, MySQL, SQLite)
│ ├── prisma-adapter/ # Prisma Store
│ ├── react/ # React components
│ ├── next/ # Next.js middleware + route guards
│ ├── nextauth/ # NextAuth v5 adapter
│ └── cli/ # CLI commands
├── examples/
│ └── nextjs-app/ # Full example app (AppShell + RBAC panel)
│ ├── src/app/
│ │ ├── admin/ # 🔐 Admin Panel (Super Admin gate)
│ │ ├── api/admin/ # RESTful management APIs
│ │ ├── features/ # 📊 Feature Dashboard
│ │ └── usage-kit/ # 📚 Usage Kit
│ └── prisma/ # schema.prisma + seed.ts
├── docs/ # Release documentation (plain Markdown, bilingual)
│ ├── en/ # English
│ └── tr/ # Türkçe
└── README.md # This fileAdmin API Routes (examples/nextjs-app)
RESTful resource routes — each resource in its own route.ts with HTTP methods:
| Endpoint | Methods | Description |
|----------|---------|-------------|
| /api/admin/users | GET POST PATCH DELETE | List / create / edit / delete users |
| /api/admin/roles | POST PATCH DELETE | Create / update / delete roles |
| /api/admin/permissions | POST PATCH DELETE | Create / update / delete permissions |
| /api/admin/user-roles | POST DELETE | Assign / remove a role to a user |
| /api/admin/user-permissions | POST DELETE | Grant / revoke a direct permission to a user |
| /api/admin/role-permissions | POST DELETE | Bind / unbind a permission to a role |
| /api/admin/state | GET | Fetch the entire RBAC state (users/roles/permissions) |
| /api/admin/seed-examples | POST | Load example data (6 roles + 15 permissions) |
| /api/admin/reset | POST | Reset the RBAC data |
🧪 Test Coverage
@typescript-permission/core 335 tests (35 files)
@typescript-permission/prisma 46 tests (11 files)
@typescript-permission/drizzle 25 tests (5 files)
@typescript-permission/next 25 tests (5 files)
@typescript-permission/react 26 tests (1 file)
@typescript-permission/nextauth 27 tests (7 files)
@typescript-permission/cli 48 tests (9 files)
─────────────────────────────────────────────
Total 500+ testsTest Coverage Matrix
| # | Scenario | Core | Prisma | Drizzle | Next | React | NextAuth | CLI | E2E | |---|----------|:----:|:------:|:-------:|:----:|:-----:|:--------:|:---:|:---:| | M1 | Basic Usage (assignRole, givePermissionTo, can) | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | | M2 | Direct Permissions (give, revoke, sync) | ✅ | ✅ | ✅ | — | ✅ | — | ✅ | ✅ | | M3 | Permissions via Roles (attach, via-roles) | ✅ | ✅ | ✅ | — | — | — | ✅ | ✅ | | M3b | Roles/Permissions with Enums (BackedEnum) | ✅ | ✅ | — | — | — | — | — | — | | M4 | Wildcard Permissions (posts.*, *.create) | ✅ | ✅ | — | — | — | — | — | ✅ | | M5 | Multiple Guards (web/api, cross-guard) | ✅ | ✅ | — | ✅ | — | — | — | ✅ | | M6 | Teams (team-scoped role, global role) | ✅ | ✅ | — | ✅ | — | — | — | ✅ | | M7 | Cache (TTL expiry, forgetCachedPermissions) | ✅ | ✅ | — | — | — | — | ✅ | ✅ | | M7b | Events (RoleAttached/Detached, PermissionAttached) | ✅ | — | — | — | — | — | — | ✅ | | M8 | Super-Admin / Gate (before hook, can, canAny) | ✅ | — | — | ✅ | — | — | — | ✅ | | M9 | Middleware (role/globalRole/permission/role_or_permission) | — | — | — | ✅ | — | — | — | — | | M9b | Route Guard Helpers (authorizeRole, authorizeGlobalRole, authorizePermission) | — | — | — | ✅ | — | — | — | — | | M9c | Response Helper (unauthorizedResponse) | — | — | — | ✅ | — | — | — | — | | M10 | Snapshot (buildSnapshot, PermissionSnapshot) | ✅ | — | — | — | ✅ | ✅ | — | — | | M11 | NextAuth Integration (sessionToModel, augmentJWT) | — | — | — | — | — | ✅ | — | — | | M12 | Exception Management (10 exception types) | ✅ | — | — | — | — | — | — | ✅ | | M13 | Scoped API (forUser, forRole) | ✅ | — | — | — | — | — | — | ✅ | | M14 | React Directive Equivalents | — | — | — | — | ✅ | — | — | — | | M15 | Model Policies (permission + ownership + super-admin) | ✅ | — | — | — | — | — | — | — | | M16 | Database Seeding (idempotent seed) | — | ✅ | — | — | — | — | — | — | | M17 | Extending (Custom Store wrapper) | ✅ | — | — | — | — | — | — | — | | M18 | UUID/ULID (string UUID, numeric ID) | — | ✅ | — | — | — | — | — | — | | M19 | Custom Permission Check (external claim, multi-hook) | ✅ | — | — | — | — | — | — | — | | M20 | Exception Display Config | ✅ | — | — | — | — | — | — | — | | M21 | CLI Artisan Commands | — | — | — | — | — | — | ✅ | — | | M22 | Role/Permission Management (update/delete) | ✅ | ✅ | ✅ | — | — | — | — | — |
Running Tests
cd packages/core && npx vitest run # 335 tests (35 files)
cd packages/prisma-adapter && npx vitest run # Prisma integration
cd packages/drizzle-adapter && npx vitest run # Drizzle integration
cd packages/react && npx vitest run # React components
cd packages/next && npx vitest run # Middleware
cd packages/nextauth && npx vitest run # NextAuth
cd packages/cli && npx vitest run # CLI
# Whole project (build + typecheck + test)
pnpm verify🔧 Development
pnpm install # Install
pnpm build # Build (all packages, topological order)
pnpm --filter @typescript-permission/core build # Build a single package
pnpm --filter @typescript-permission/core test # Test a single package
# Database (example app)
cd examples/nextjs-app
pnpm db:push # Apply the Prisma schema
pnpm db:seed # Load seed data
pnpm dev # localhost:3000🔒 Security Usage Notes
Authorization methods and gate behavior
| Method | Goes through the gate? | Super-Admin bypass? | Recommended use |
| --- | --- | --- | --- |
| authorizePermission / can / canAny | ✅ Yes | ✅ Yes | General check |
| authorizeRole / hasRole | ❌ No | ❌ No | Direct role |
| authorizeGlobalRole / hasGlobalRole | ❌ No | ❌ No | Team-safe global role (platform-level; matches only teamId === null roles, so a tenant's own-team role of the same name does not pass) |
| authorizeRoleOrPermission | ⚠️ Partial | ⚠️ Mixed | Flexible |
If you defined a super-admin before hook, role-protected routes are not affected by the bypass. This matches the intended behavior.
hasPermissionTo and unknown permissions
- Wildcard mode off → throws
PermissionDoesNotExist. - Wildcard mode on → returns
false.
For consistent, non-throwing behavior, use checkPermissionTo.
Reserved names and characters
Permission/role names cannot be "*" and cannot contain the | character. "*" is the universal matcher in the wildcard engine, and | is the OR separator. Both are rejected in createRole/createPermission and updateRole/updatePermission.
Teams and concurrency
When teams are enabled (teams: true), you must inject a request-scoped resolver. The default DefaultTeamResolver holds a single mutable state and can leak team context across concurrent requests.
403 response body
By default unauthorizedResponse does not write the required role/permission names into the body (to prevent information leakage). Request them explicitly with includeRequirements: true for diagnostics.
filterFields is not a security boundary
filterFields / permittedFields are serialization helpers — the data is already in memory. Filtering it before serializing it into the response is the caller's job. The security boundary is the query that loaded the data (and accessibleBy for list endpoints).
"conditional" snapshot state is truthy
PermissionState = boolean | "conditional". Never test the raw value for truthiness (if (snap.permissions[x]) or !!); "conditional" is a non-empty string and evaluates to true. Use === true, or rely on the React hooks/components which already do this comparison correctly.
📚 Detailed Documentation
docs/— bilingual (English · Türkçe), feature-per-page documentation/usage-kit(live) — See every scenario running
License
MIT © Yigit Isman
