permxv0.4
module·resource·field·action·scope→zero runtime deps///diamond-safe inheritance→framework-agnostic///headless react sdk→~5 KB///module·resource·field·action·scope→zero runtime deps///diamond-safe inheritance→framework-agnostic///headless react sdk→~5 KB///
Release 0.4 · Apr 2026——— permx/core · permx/react

Structured RBAC
for the apps you actually ship.

Permission keys with meaning. Roles that inherit without cycles. UI mappings baked into every rule. A framework-agnostic engine with zero runtime dependencies, paired with a headless React SDK that weighs ~5 KB.

bun add @permx/coreRead the anatomy →MIT · TypeScript 5.7+ · Node 18+

FIG.01 — permission-key anatomy · click to deny a coordinate

.:..
ALLOW · authorize()

MODULE

Top-level product area. projects, billing, admin.

RESOURCE

What you act on. tasks, invoices, users.

FIELD

Optional. Column-level gate. revenue, salary.

ACTION

view · create · update · delete · manage.

SCOPE

all · own · team · department · self · public · admin.

§ 01 · thesis

why not a flat string?

Flat strings pretend authorization is a vocabulary problem. It isn't. It's a coordinate problem.

When your app has 300 permissions, read:users and view_user drift apart, middleware forgets, and the UI hardcodes its own rules. PermX fixes each permission at a point in the product — module, resource, field, action, scope — so backend checks and UI gates speak the same language, and a refactor renames it in one place.

  • ABAC

    Too abstract. Policy language per app.

  • Flat RBAC

    String soup. No UI contract.

  • Proprietary SaaS

    Vendor-locked. Your rules, their DB.

  • PermX

    Coordinates. Zero deps. Yours forever.

§ 02 · coordinates

Five coordinates. One permission key.

M01

Module

projects

Top-level product surface. Seeded once, referenced everywhere.

R02

Resource

tasks

The entity or collection being accessed. The noun of every check.

F03

Field

:revenue

Optional column-level gate. Use for salaries, PII, payment data.

A04

Action

view

Fixed enum: view, create, update, delete, manage. No drift.

S05

Scope

own

Row-level boundary: all, own, team, department, self, public, admin.

definePermissions() · literal-string types preserved

Declare your permissions once. TypeScript infers the literal key strings and hands you autocomplete across middleware, hooks, and gates. Rename a coordinate — the compiler finds every call site.

permissions.ts
01// permissions.ts — single source of truth
02import { definePermissions, type PermissionKeyOf } from '@permx/core'
03
04export const P = definePermissions({
05  projectsView: { module: 'projects', resource: 'tasks', action: 'view', scope: 'all' },
06  viewSalary:   { module: 'people', resource: 'employees', action: 'view', scope: 'own', field: 'salary' },
07} as const);
08
09// → "people.employees:salary.view.own" (literal type)
10type AppPerm = PermissionKeyOf<typeof P>;
§ 03 · before · after

Delete string soup.
Keep the refactor.

BEFORE · flat-string rbac

routes.tsdrift · no types
-if (user.perms.includes('read:users'))-if (user.perms.includes('view_user'))-if (user.perms.includes('users.read'))-// UI layer hardcodes its own rules-if (role === 'admin' || role === 'adm')-// one rename = grep across 40 files

AFTER · permx structured keys

routes.tstyped · rename-safe
+auth.authorize(P.usersRead)+<Can componentId="users-table">+<CanField fieldId="salary" />+<RouteGuard routeId="/admin" />+// rename 'users' → 'members':+// every call site fails typecheck.
§ 04 · effective-set

Three independent layers. One union.

Effective permissions = Regular Roles ∪ Subscription Plan ∪ Feature Flags. Each layer flows from its own system of record — job function, Stripe webhook, rollout toggle — without a custom policy engine to glue them together.

resolver runs on every authorize() call
ttl cache keyed by tenantId::userId

∪

Layer 01 — Regular Roles

per-user

Job-function assignments. Editor, Viewer, Admin. DFS inheritance with cycle protection, depth cap 10.

Layer 02 — Subscription

per-tenant

Plan tier features. Free, Pro, Enterprise. Resolver takes tenantId and returns role ids — Stripe webhooks update a single column.

Layer 03 — Feature Flags

per-tenant

Gradual rollouts. Beta AI assistant, experimental UI. Same permission grammar — no separate flag SDK.

§ 05 · inheritance

Diamond-safe DFS.
Cycles die quietly.

Role inheritance graph. Owner inherits Admin, which splits into Editor and Billing. Both Editor and Billing inherit Viewer — a diamond topology that PermX resolves with a visited-set DFS.

Owner
Admin
Editor
Billing
Viewer
DIAMOND

visited-set

Set<string> — O(n) dedupe

depth cap

10 — DoS guard, emits warning

merge strategy

Set union — order-independent

§ 06 · implementation

One key. Two sides of the wire.

01 — BACKEND · @permx/core/express

Protect the route.

server.ts
01import { createPermXMiddleware } from '@permx/core/express'
02
03const auth = createPermXMiddleware(permx, {
04  extractUserId: (req) => req.user?.id,
05});
06
07app.get('/projects',
08  auth.authorize(P.projectsView),
09  listProjects);
10
11// 403 auto-sent. typed permission key.
12// swap for hono / fastify — same engine.

02 — FRONTEND · @permx/react

Gate the UI.

App.tsx
01import { Can, CanField, RouteGuard } from '@permx/react'
02
03<Can componentId="edit-project-btn">
04  <EditButton />
05</Can>
06
07<CanField fieldId="salary">
08  <SalaryInput />
09</CanField>
10
11<RouteGuard routeId="/admin" fallback={<NoAccess />}>
12  <AdminPage />
13</RouteGuard>

same permission key on both sides · ui mappings ship from the db · no hardcoded rules in the app

§ 07 · spec sheet

Read the spec.
Skip the sales deck.

CapabilityCASLCasbinPermit.ioPermX
Structured keys (module.resource:field.action.scope)NoNoNoFirst-class grammar
UI mappings (routes · components · fields)NoNoNoBaked into each permission
3-layer model (roles + subscription + flags)NoNoPartialUnion-resolved per call
Role inheritance · DFS + cycle guardNoPolicy-basedManagedDFS · depth 10
Framework-agnostic (Express · Hono · Fastify · Koa)ExpressYesSaaS onlyAny HTTP layer
DB-agnostic (adapter pattern)NoYesSaaS onlyPermXDataProvider
React SDK — components + hooks + store<Can> onlyNoneNoneFull suite · ~5 KB
Runtime deps (core)———0
§ 08 · by the numbers

0

runtime dependencies

core engine · mongoose + express are optional peers

~0

kilobytes react sdk

gzipped · zero runtime deps · useSyncExternalStore backed

0

test cases · green

204 core · 57 react · vitest 3 · jsdom + node

0

depth cap on DFS

diamond + cycle safe · depth warn event emitted

§ 09 · frequently asked

Objections,
addressed.

How is this different from CASL?
CASL reasons about abilities against subjects. PermX locks every permission to a fixed module.resource:field.action.scope coordinate, and ships those same coordinates to the UI as route / component / field ids. You get typed autocomplete, single-source renames, and no policy language on top.
Do I need MongoDB or Express?
No. The core is storage- and framework-agnostic. Mongoose and Express ship as optional peers — the engine runs anywhere you can call a function. Bring Postgres, Prisma, Hono, Fastify, Bun.serve — implement one PermXDataProvider and you are wired.
What about row-level / ABAC-style rules?
Scope covers the common cases: all, own, team,department, self. For arbitrary predicates, wrap the authorize call in your resolver — PermX stays out of your query builder.
Is it safe to put permissions in a database?
Yes — that's the point. Roles, role-permissions, and UI mappings live in your own DB behind your own auth. PermX caches the graph per tenant, invalidates on write, and bounds DFS to depth 10 so a malformed inheritance tree can't pin the event loop.
Coming from a flat-RBAC codebase. What is the migration like?
Start incremental. Map your existing permission strings to coordinates one module at a time with definePermissions(). PermX will happily coexist with flat checks until you are ready to flip the switch. Most teams migrate over two sprints.
Why zero runtime dependencies?
Supply-chain risk and bundle weight. Auth is in your hot path on every request and every render — it should not pull a transitive tree you haven't audited. Core is hand-written TypeScript with no imports outside the standard library.
§ 10 · humans

Built in the open.
By hand.

PermX is independent, MIT-licensed, and open to contributions. Issues, PRs, and architectural debates are welcome on GitHub — the core stays zero-dep on purpose, and we'd rather ship a small thing well than a big thing badly.

§ 11 · ship

Install.
Seed. Authorize.

Works with any Node.js ≥ 18. Bun-first but npm / pnpm / yarn fine. Bring your own auth — PermX only needs a userId.

BACKEND

$bun add @permx/core mongoose express

FRONTEND

$bun add @permx/react

NPM USERS

$npm install @permx/core @permx/react

quick start · three calls to first authorize()

Define permissions

One call to definePermissions(). TypeScript captures every literal key — no runtime cost.

const P = definePermissions({…})

Spin up the engine

Pass your data provider (Mongo, Postgres, in-memory). The engine returns the resolver used by both server and client.

createPermXEngine({ dataProvider })

Gate server + UI

Middleware on the route, <Can> in the component. Same key on both sides of the wire.

auth.authorize(P.projectsView)