permzplus
v4.6.1
Published
RBAC + ABAC permissions for TypeScript — hierarchical roles, MongoDB-style subject conditions, and database query generation
Maintainers
Readme
permzplus
RBAC + ABAC + ReBAC authorization for TypeScript, zero dependencies
permzplus is a small, friendly permissions library that does the three big authorization models in one place: role hierarchies (RBAC), attribute conditions (ABAC), and relationship tuples (ReBAC). It's faster and smaller than CASL on the tree-shaken hot path, ships query generation for 10 ORMs, edge-safe JWT bitmasks, and adapters for Clerk, Auth.js, Next.js App Router, React, and Vue. No transitive dependencies, no surprises.
The Builder
// Define a role // What you get back
import { createPermz, PolicyEngine } from 'permzplus'
const snapshot = createPermz({ name: 'EDITOR', level: 20 })
.can('read', 'posts')
.can('write', 'posts')
.cannot('delete', 'posts')
.build(){
"roles": [{ "name": "EDITOR", "level": 20, "permissions": ["posts:read", "posts:write"] }],
"denies": { "EDITOR": ["posts:delete"] },
"groups": {}
}const policy = PolicyEngine.fromJSON(snapshot)
policy.can('EDITOR', 'posts:read') // true
policy.can('EDITOR', 'posts:delete') // falseFull TypeScript generics let you lock down valid actions and resources at compile time:
type Action = 'read' | 'write' | 'delete'
type Resource = 'posts' | 'comments'
createPermz<Action, Resource>({ name: 'MOD', level: 30 })
.can('purge', 'posts') // TS error: 'purge' is not a valid ActionWhy permzplus
| | permzplus | CASL | Casbin |
|---|---|---|---|
| RBAC (role hierarchies) | Yes | Yes | Yes |
| ABAC (subject conditions) | Yes | Yes | Partial |
| ReBAC (relationship tuples) | Yes | No | No |
| Dependencies | 0 | 0 | 0 |
| Resolver | Two-level cache | Index + condition walk | Regex policy scan |
| ABAC query gen | Prisma, Mongoose, Drizzle, Kysely, TypeORM, Sequelize, Knex | MongoDB only | No |
| JIT compiler | compilePolicy() | No | No |
| JWT bitmask sync | Clerk, Auth.js, jose | No | No |
| Next.js App Router | Server + Client + Middleware | No | No |
| Tree-shaken core (gzip) | 5.3 KB | 6.2 KB | ~14 KB |
| .can() cached hit | ~9 ns | ~11 ns | ~620 ns |
Smaller and faster than CASL on the hot path, with features neither CASL nor Casbin offer. (Casbin would also need a config file and probably a coffee.)
Performance
The hot path is a tight two-level lookup with zero string allocation, zero regex, and no property reads beyond checkCache:
checkCache[role]?.[permission] → return booleanThe full resolver has three layers:
checkCache: two-level object lookup for repeated subject-free calls (the hot path above)- Bitwise layer: bitmask check for
read / write / delete / createwithout iterating the permission set - Set iteration: fallback for custom actions or ABAC subject conditions
All three caches are invalidated atomically on any mutation. The slow-path code lives in a separate method so V8 can keep the hot .can() as small and inlinable as possible.
vs. CASL and accesscontrol
Benchmarked with mitata on Node 22.16.0, 13th Gen Intel Core i7-1355U. Policy: 3 roles (VIEWER → EDITOR → ADMIN), hierarchical inheritance. Steady-state (cache warm). Numbers below are avg per iter from a representative run. Heads up: micro-benchmarks at the nanosecond scale are noisy, so trust the relative ordering more than the absolute numbers.
| Scenario | permzplus | CASL | accesscontrol | |---|---|---|---| | VIEWER read Post (allowed) | 9.13 ns | 11.31 ns | 616 ns | | EDITOR write Post (allowed) | 10.62 ns | 11.47 ns | 965 ns | | ADMIN wildcard delete (allowed) | 9.16 ns | 10.14 ns | 1,140 ns | | VIEWER delete Post (denied) | 9.71 ns | 14.75 ns | 960 ns | | 1,000,000 ops total time | 13.48 ms | 14.40 ms | 1,430 ms | | Throughput | ~74M ops/sec | ~69M ops/sec | ~700K ops/sec |
permzplus is consistently faster than CASL on every scenario (1.1× to 1.5× faster depending on the operation) and 65×–125× faster than accesscontrol. (accesscontrol is still finishing the check from earlier in this paragraph.)
Bundle size
Whole-file dist size can be misleading. What your bundler actually ships into your app is the tree-shaken subset of what you import. The numbers below come from a minimal app that does import { PolicyEngine } from 'permzplus' (or the equivalent CASL import), bundled with esbuild in production mode.
| | permzplus | CASL (@casl/ability) |
|---|---|---|
| Tree-shaken raw | 17.7 KB | 16.5 KB |
| Tree-shaken gzip | 5.3 KB | 6.2 KB |
| Dependencies | 0 | 0 |
permzplus's tree-shaken core is ~14% smaller than CASL on the wire (5.3 KB vs 6.2 KB gzip). Opt-in features live in sub-entries (permzplus/rebac, permzplus/jit, permzplus/query, permzplus/jwt, permzplus/nextjs/middleware, and friends) so they only enter your bundle when you actually import them. (Casbin's main entry is 14 KB. We rest our case.)
Want to verify? Run
pnpm bench:compare. Source:bench/benchmark.ts.
JIT Policy Compiler
CASL doesn't have one of these. Just saying.
compilePolicy() takes your permission rules and emits a runtime function via new Function(), which V8's Turbofan can then optimise. Every bitmask is unrolled at compile time into a branchless boolean expression, so the resulting checker has zero loops, zero property accesses, and (after warmup) zero heap allocation on the hot path.
import { compilePolicy, fromPermBits, BIT_READ, BIT_WRITE, BIT_DELETE } from 'permzplus'
// Compile once after your policy is finalised. Never inside a hot path.
const check = compilePolicy([
{ require: BIT_READ },
{ require: BIT_READ | BIT_WRITE, deny: BIT_DELETE },
])
check(BIT_READ) // true
check(BIT_READ | BIT_WRITE) // true
check(BIT_DELETE) // false (DELETE only, no rule passes)For maximum speed, use fromPermBits() to bake the engine's static bitmask cache directly into the compiled expression:
// Build a zero-overhead checker from the engine's internal cache
const engine = PolicyEngine.fromJSON(snapshot)
const bits = engine['permBitsCache'].get('EDITOR')!
const canReadPosts = fromPermBits(bits, 'posts', BIT_READ)
// If EDITOR always has posts:read, this compiles to: return true
// Static grant: Turbofan can inline it as a constant| | JIT (compilePolicy) | Standard can() |
|---|---|---|
| Branches | 0 (unrolled) | 2–3 |
| Heap allocation | none after warmup | none |
| Use case | ultra-hot checks (per-row filtering, render guards) | general use |
The compiled function shape is always (m: number) => boolean: monomorphic, single IC slot.
Installation
npm install permzplus
# or
pnpm add permzplusCore API
Fluent Builder
import { createPermz, PolicyEngine } from 'permzplus'
const snapshot = createPermz({ name: 'ADMIN', level: 99 })
.can('read', 'posts')
.can('write', 'posts')
.can('delete', 'posts')
.build()
const policy = PolicyEngine.fromJSON(snapshot)Declarative (classic)
import { defineAbility } from 'permzplus'
const policy = defineAbility(({ role }) => {
role('SUPER_ADMIN', 3, (can) => {
can('*')
})
role('ORG_ADMIN', 2, (can, cannot) => {
can('sites:*', 'templates:*', 'users:read')
cannot('billing:delete')
})
role('MEMBER', 1, (can) => {
can('content:read', 'content:create', 'posts:read', 'posts:edit')
})
})
policy.can('ORG_ADMIN', 'sites:create') // true (direct grant)
policy.can('ORG_ADMIN', 'content:read') // true (inherited from MEMBER)
policy.can('MEMBER', 'billing:delete') // false (explicit deny)
policy.safeCan('', 'content:read') // false (safe for unauthenticated users)ABAC: Attribute-Based Conditions
Object conditions (serializable)
MongoDB-style operators that work with both can() and accessibleBy() for query generation.
// Only published posts
policy.defineRule('MEMBER', 'posts:read', { status: 'published' })
// Only the user's own posts. The possession macro expands {{user.id}} at runtime.
policy.defineRule('MEMBER', 'posts:edit', { authorId: '{{user.id}}' })
policy.can('MEMBER', 'posts:read', { status: 'published' }, { user: { id: 'u1' } }) // true
policy.can('MEMBER', 'posts:read', { status: 'draft' }, { user: { id: 'u1' } }) // falseFunction conditions
policy.defineRule('MEMBER', 'posts:edit',
(post, ctx) => post.authorId === ctx?.userId && post.status !== 'locked'
)
policy.can('MEMBER', 'posts:edit', post, { userId: 'u1' })Possession Macros
Use {{dot.path}} in object conditions to inject runtime context values without writing a function. The path resolves against the context object you pass to can().
policy.defineRule('MEMBER', 'posts:edit', { authorId: '{{user.id}}' })
policy.defineRule('MEMBER', 'comments:edit', { authorId: '{{user.id}}', tenantId: '{{tenant.id}}' })Mixed strings work too: "org-{{tenant.id}}" becomes "org-acme".
Supported Operators
Built-in: $eq $ne $gt $gte $lt $lte $in $nin $exists $regex $and $or $nor
Time operators: $after $before $between
policy.defineRule('MODERATOR', 'posts:delete', {
status: { $in: ['flagged', 'spam'] },
reportCount: { $gte: 3 },
})
policy.defineRule('MEMBER', 'events:rsvp', {
startsAt: { $after: new Date() },
})Custom Operators
Add your own with registerOperator():
import { registerOperator } from 'permzplus'
registerOperator('$startsWith', (fieldValue, operand) =>
typeof fieldValue === 'string' && fieldValue.startsWith(operand as string)
)
policy.defineRule('ADMIN', 'files:read', { path: { $startsWith: '/public/' } })Per-Request Context
const ctx = policy.createContext('MEMBER', { userId: req.user.id })
ctx.can('posts:edit', post) // condition receives { userId: req.user.id }
ctx.cannot('posts:delete', post)
ctx.assert('posts:edit', post) // throws PermissionDeniedError if deniedReBAC: Relationship-Based Access Control
Zanzibar-style tuples for "who is related to what." Use this when permissions follow object ownership instead of (or alongside) abstract roles. Think user:u1 is the owner of doc:d1 or user:u2 is a member of org:acme. CASL and Casbin don't ship this. You're welcome.
ReBAC lives in its own sub-entry. Importing it patches .relate, .related, .defineRebacSchema (and the rest) onto PolicyEngine. If you don't need ReBAC, you don't pay the bundle cost.
import 'permzplus/rebac' // side-effect import: adds the methods below to PolicyEngineDirect tuples
The simplest form needs no schema. Just write (subject, relation, object) triples and ask if a relationship exists.
policy.relate('user:u1', 'member', 'org:acme')
policy.related('user:u1', 'member', 'org:acme') // → true
policy.unrelate('user:u1', 'member', 'org:acme')Computed relations (union rewrites)
Define a schema so one relation can imply another. Classic example: an owner is also an editor, an editor is also a viewer.
policy.defineRebacSchema({
doc: {
owner: { this: true },
editor: { this: true, union: ['owner'] },
viewer: { this: true, union: ['editor'] },
},
})
policy.relate('user:u1', 'owner', 'doc:d1')
policy.related('user:u1', 'owner', 'doc:d1') // → true
policy.related('user:u1', 'editor', 'doc:d1') // → true (via owner → editor)
policy.related('user:u1', 'viewer', 'doc:d1') // → true (via owner → editor → viewer)this: true means direct tuples grant this relation. union lists other relations on the same object type whose holders are automatically granted this one. Set this: false for a purely computed relation that ignores direct tuples.
Listing
policy.listRelatedObjects('user:u1', 'viewer') // → ['doc:d1', 'doc:d2', ...]
policy.listRelatedSubjects('viewer', 'doc:d1') // → ['user:u1', 'user:u2', ...]Both walk the schema's rewrite chain, so a user with owner shows up when you list viewers.
Snapshot roundtrip
ReBAC state (schema + tuples) is included in policy.toJSON() under the optional rebac field, so PolicyEngine.fromJSON() restores everything in one go.
What v1 does and doesn't cover
- ✓ Tuples + direct lookups
- ✓ Computed relations via
union - ✓ Cycle protection in the resolver
- ✗ Tuple-to-userset (parent traversal, like "viewer of doc = viewer of doc's parent folder"). Planned for v2.
- ✗ Pluggable tuple storage (in-memory only for now).
Field-Level Permissions
policy.addRole({
name: 'EDITOR',
level: 2,
permissions: ['post.title:edit', 'post.body:edit', 'post.status:read'],
})
policy.permittedFieldsOf('EDITOR', 'post', 'edit') // ['title', 'body']
policy.permittedFieldsOf('EDITOR', 'post', 'read') // ['status']Query Builder
accessibleBy() converts your ABAC rules into database WHERE clauses, so you can derive access filters directly from your policy. CASL does this for MongoDB. We do it for ten databases.
import { accessibleBy } from 'permzplus/query'
const { permitted, unrestricted, conditions } = accessibleBy(policy, 'MEMBER', 'posts:read')
// Prisma
const posts = await prisma.post.findMany({
where: !permitted ? { id: 'never' } : unrestricted ? {} : { OR: conditions },
})
// Mongoose
const posts = await Post.find(unrestricted ? {} : { $or: conditions })Multi-Role Merge
import { mergeAccessible } from 'permzplus/query'
const access = mergeAccessible(
accessibleBy(policy, 'MEMBER', 'posts:read'),
accessibleBy(policy, 'MODERATOR', 'posts:read'),
)| Field | Type | Meaning |
|---|---|---|
| permitted | boolean | Role has this permission at all |
| unrestricted | boolean | Permitted with no conditions (return all records) |
| conditions | object[] | MongoDB-style OR filter conditions |
Permission Groups
Reuse sets of permissions across roles. Compose groups with @ref syntax. Cycle detection is built in.
const policy = defineAbility(({ role, group }) => {
group('content-viewer', ['posts:read', 'comments:read'])
group('content-editor', ['@content-viewer', 'posts:write', 'comments:write'])
role('MEMBER', 1, (can) => can('#content-viewer'))
role('EDITOR', 2, (can) => can('#content-editor')) // inherits viewer permissions
})Delegation & Impersonation
Temporarily elevate or transfer permissions, optionally scoped to a subset of the delegator's rules.
// Full delegation
const delegated = policy.delegate('ADMIN', 'temp-user-id')
// Scoped delegation: only these permissions are forwarded
const scoped = policy.delegate('ADMIN', 'temp-user-id', ['posts:read', 'posts:write'])Expiring Role Assignments
policy.assignRole('MEMBER', userId, {
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
})
// Expired assignments are filtered automatically on every checkAudit Logging
import { InMemoryAuditLogger } from 'permzplus'
const audit = new InMemoryAuditLogger()
const policy = new PolicyEngine({ audit })
policy.grantTo('MEMBER', 'posts:create')
// Simple access
audit.getEvents() // all events
audit.forUser('u1') // events for a specific user
audit.forRole('MEMBER') // events for a specific role
audit.since(new Date('2025-01-01')) // events since a date
// Composable queries
audit.query({
action: 'permission.grant',
role: 'MEMBER',
since: new Date('2025-01-01'),
order: 'desc',
limit: 50,
})Import / Export
// Serialization (send over the wire)
const snapshot = policy.toJSON()
const policy = PolicyEngine.fromJSON(snapshot)
// CSV bulk import / export
const csv = policy.toCSV()
const policy = await PolicyEngine.fromCSV(csvString)
// Bulk JSON import
await PolicyEngine.fromBulkJSON(jsonArray)Standalone Validator
import { validate } from 'permzplus/validator'
const issues = validate(snapshot)
// ValidationIssue types:
// orphaned_group | invalid_level | duplicate_level | invalid_permission | undefined_group_refGraphQL
import { withPermission } from 'permzplus/adapters/graphql'
const resolvers = {
Mutation: {
deletePost: withPermission(policy, 'posts:delete', async (_, args, ctx) => {
return deletePost(args.id)
}),
},
}tRPC
import { trpcPermission } from 'permzplus/adapters/trpc'
const protectedProcedure = t.procedure.use(trpcPermission(policy, 'posts:write'))Framework Adapters
// Express
import { expressGuard } from 'permzplus/guard'
app.delete('/posts/:id', expressGuard(policy, 'posts:delete'), handler)
// Fastify
import { FastifyPermzPlugin } from 'permzplus/adapters/fastify'
fastify.register(FastifyPermzPlugin, { policy })
// Hono
import { honoPermzMiddleware } from 'permzplus/adapters/hono'
app.use('/admin/*', honoPermzMiddleware(policy, 'admin:panel'))
// NestJS
import { PermzGuard, RequirePermission } from 'permzplus/adapters/nest'
@UseGuards(PermzGuard)
@RequirePermission('posts:delete')
async deletePost() { ... }Database Adapters
import { PrismaAdapter } from 'permzplus/adapters/prisma'
import { MongooseAdapter } from 'permzplus/adapters/mongoose'
import { DrizzleAdapter } from 'permzplus/adapters/drizzle'
import { FirebaseAdapter } from 'permzplus/adapters/firebase'
import { SupabaseAdapter } from 'permzplus/adapters/supabase'
import { RedisAdapter } from 'permzplus/adapters/redis'
import { TypeORMAdapter } from 'permzplus/adapters/typeorm'
import { KnexAdapter } from 'permzplus/adapters/knex'
import { SequelizeAdapter } from 'permzplus/adapters/sequelize'
import { KyselyAdapter } from 'permzplus/adapters/kysely'
const policy = await PolicyEngine.fromAdapter(new PrismaAdapter(prisma))Auth Adapters
Clerk (permzplus/adapters/clerk)
import { clerkClient } from '@clerk/nextjs/server'
import { computeClerkMetadata, clerkBitmaskGuard, fromClerkClaims } from 'permzplus/adapters/clerk'
import { policy } from '@/lib/policy'
// 1. Sync the bitmask when a role changes
async function assignUserRole(userId: string, role: string) {
await clerkClient.users.updateUser(userId, {
publicMetadata: computeClerkMetadata(policy, role),
})
}
// 2. Edge middleware
const guard = clerkBitmaskGuard([
{ pattern: '/dashboard', permission: 'dashboard:view' },
{ pattern: /^\/admin/, permission: 'admin:access', redirectTo: '/403' },
], { loginUrl: '/sign-in' })
export default clerkMiddleware((auth, req) => {
return guard(auth().sessionClaims, req) ?? NextResponse.next()
})
// 3. Server Component
const perms = fromClerkClaims(auth().sessionClaims)
if (perms.cannot('reports:view')) redirect('/403')Auth.js / NextAuth (permzplus/adapters/authjs)
// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import { permzCallbacks } from 'permzplus/adapters/authjs'
import { policy } from '@/lib/policy'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
...permzCallbacks(policy, {
getRole: (user) => (user as any).role ?? 'GUEST',
}),
})
// Server Component
const session = await auth()
if (!session?.user.permissions.can('dashboard:view')) redirect('/403')
// Client Component: pass the bitmask string to usePermissions()
import { usePermissions } from 'permzplus/nextjs/client'
const perms = usePermissions(session?.user?.permzBitmask ?? '')Kysely (permzplus/adapters/kysely)
import { KyselyAdapter, createPermzMigration } from 'permzplus/adapters/kysely'
import { db } from '@/db'
// Run once to create permz tables
const { up, down } = createPermzMigration()
// Load policy from DB
const engine = await PolicyEngine.fromAdapter(new KyselyAdapter(db))React
import { PermissionProvider, useAbility, Can } from 'permzplus/react'
function App() {
return (
<PermissionProvider engine={policy} role={user.role ?? ''}>
<Dashboard />
</PermissionProvider>
)
}
function EditButton({ post }) {
const ability = useAbility()
if (!ability.can('posts:edit', () => post.authorId === userId)) return null
return <button>Edit</button>
}
// Declarative form (CASL-style I/a props are supported)
function Toolbar() {
return <Can I="delete" a="post"><DeleteButton /></Can>
}JWT Sync Layer
Embed permission bitmasks into JWTs so Edge middleware and Client Components can check permissions without a database call.
Stuff claims at login (permzplus/jwt)
import { stuffBitmask } from 'permzplus/jwt'
import { policy } from '@/lib/policy'
const claims = stuffBitmask(policy, user.role)
// → { permz: { b: 'cG9zdHM6cmVhZA', iat: 1714000000 } }Clerk, merge into publicMetadata:
await clerkClient.users.updateUser(userId, { publicMetadata: claims })Auth.js, return from the jwt() callback:
callbacks: {
jwt({ token, user }) {
if (user?.role) Object.assign(token, stuffBitmask(policy, user.role))
return token
}
}jose, spread into your payload before signing:
const jwt = await new SignJWT({ sub: userId, ...stuffBitmask(policy, role) })
.setProtectedHeader({ alg: 'HS256' })
.sign(secret)Decode on the Edge (no DB call, no engine import)
import { decodeJWTBitmask } from 'permzplus/jwt'
// From a raw Bearer token
const perms = decodeJWTBitmask(req.headers.get('authorization')?.slice(7))
if (perms.cannot('reports:view')) return new Response('Forbidden', { status: 403 })
// From Clerk's already-decoded sessionClaims
const perms = decodeJWTBitmask(auth().sessionClaims)JWT-aware route guard (Clerk middleware example)
import { clerkMiddleware } from '@clerk/nextjs/server'
import { createJWTMiddlewareGuard } from 'permzplus/jwt'
const guard = createJWTMiddlewareGuard([
{ pattern: '/dashboard', permission: 'dashboard:view' },
{ pattern: /^\/admin/, permission: 'admin:access', redirectTo: '/403' },
], { loginUrl: '/sign-in' })
export default clerkMiddleware((auth, req) => {
return guard(req) ?? NextResponse.next()
})Type-safe JWT payloads
import type { SafeJWTPayload } from 'permzplus/jwt'
// Statically prevents serialising engine/policy state into a JWT
type AppToken = SafeJWTPayload<{ sub: string; role: string }>
const bad: AppToken = { engine: policyEngine, role: 'ADMIN' } // TS error
const good: AppToken = { role: 'ADMIN', permz: { b: bitmask } } // OKNext.js App Router
Three entry points, so you import exactly what you need.
Server Components & Server Actions (permzplus/nextjs)
// app/dashboard/page.tsx (Server Component, no 'use client')
import { CanServer, getPermissionMap } from 'permzplus/nextjs'
import { policy } from '@/lib/policy'
import { getSession } from '@/lib/auth'
export default async function DashboardPage() {
const session = await getSession()
const permMap = getPermissionMap(policy, session.role)
return (
<>
{/* Gate a subtree on the server: zero client bundle cost */}
<CanServer permission="reports:view" engine={policy} role={session.role}>
<ReportsDashboard />
</CanServer>
{/* Pass the bitmask down so Client Components can check locally */}
<ClientSidebar permMap={permMap} />
</>
)
}getPermissionMap() returns a plain-JSON-serializable { bitmask, role }. Safe to return from a 'use server' action or pass as a prop.
Client Components (permzplus/nextjs/client)
// app/layout.tsx: decode once, share everywhere
import { PermissionBitmaskProvider } from 'permzplus/nextjs/client'
export default async function RootLayout({ children }) {
const { bitmask } = getPermissionMap(policy, session.role)
return (
<html><body>
<PermissionBitmaskProvider bitmask={bitmask}>
{children}
</PermissionBitmaskProvider>
</body></html>
)
}
// Any client component, no server round-trip
'use client'
import { usePermissions, useCan, CanClient } from 'permzplus/nextjs/client'
function Toolbar() {
const perms = usePermissions()
const canDelete = useCan('posts:delete')
return (
<>
{canDelete && <DeleteButton />}
{/* Declarative gate */}
<CanClient permission="posts:edit" fallback={<span>Read-only</span>}>
<EditButton />
</CanClient>
</>
)
}Middleware (permzplus/nextjs/middleware)
// middleware.ts: blocks routes before the page pipeline starts, zero DB calls
import { NextRequest, NextResponse } from 'next/server'
import { createPermissionMiddleware } from 'permzplus/nextjs/middleware'
const guard = createPermissionMiddleware(
[
{ pattern: '/dashboard', permission: 'dashboard:view' },
{ pattern: /^\/admin/, permission: 'admin:access', redirectTo: '/403' },
{ pattern: '/billing', permission: 'billing:manage', redirectTo: '/upgrade' },
],
{ cookieName: 'permz', loginUrl: '/login' }
)
export function middleware(req: NextRequest) {
return guard(req) ?? NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/billing/:path*'],
}Vue
import { providePermissions, usePermission } from 'permzplus/vue'
providePermissions(policy, user.role)
const canDelete = usePermission('posts:delete') // ComputedRef<boolean>Security
- Zero runtime dependencies. Nothing transitive to audit or get compromised.
- Socket.dev report: supply-chain checks for the published package.
Spread the Word
If permzplus saves you time, please help others find it:
- Star the repo on GitHub. It really helps with discoverability.
- Share it with your team, in Discord servers, or on Twitter/X.
- Write about it. Blog posts, dev.to articles, or Stack Overflow answers go a long way.
- Open issues or PRs. Feedback and contributions make the library better for everyone.
The project is solo-maintained (by a 12-year-old). Every mention helps.
License
MIT
