npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 / filterFields with fields: { 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 — before hooks with registerPermissionCheckMethod: false for 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, extensible PermissionStore → 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-permission package 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 add

Then 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 --yes

Pick Prisma or Drizzle and init does the rest. With Prisma 7+ and an existing prisma/schema.prisma it writes the 5 RBAC models as prisma/rbac.prisma (multi-file schema: one client, one migration history; set schema: "prisma" in prisma.config.ts). Otherwise — Prisma ≤6, no schema yet, or --layout standalone — it writes a separate, self-contained schema (prisma-rbac/ or drizzle-rbac/). Tables are pushed automatically when DATABASE_URL is set; otherwise the exact command is printed. --no-push generates files only.

Multi-tenant: shared DB → --tenant-model Tenant (requires Teams; implied with --yes) adds Role.teamId → Tenant.id (cascade; you add roles Role[] to Tenant). Database-per-tenant → --tenancy database emits getPermissionServiceFor(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-permission only pulls in zod. You either install the peer of the adapter you need (e.g. @prisma/client) yourself, or let init install 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 given

Fail-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>

filterFields is 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 it

effect: "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 flush

Cache 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");  // wildcard

2️⃣ Create Roles

await service.createRole("Moderator", "web", null);       // global
await service.createRole("Team Editor", "web", "team-a");  // team-scoped

3️⃣ 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");     // true

6️⃣ 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 dev

Demo 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 file

Admin 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+ tests

Test 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