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

rbac-fs

v1.0.1

Published

Git-friendly, zero-database RBAC — roles and permissions as JSON files, multi-tenant by default, works in Node and the browser, in JS or TS.

Readme

rbac-fs

Git-friendly, zero-database RBAC — roles and permissions as JSON files, multi-tenant by default, works in Node and the browser, in JS or TS.

npm install rbac-fs gives you role-based access control that stores roles and permissions as human-readable JSON files under a .rbac/ folder — no database to stand up, no opaque policy blob, every role change is a normal, reviewable diff in your PR.

Why rbac-fs

| | rbac-fs | Casbin | AccessControl | CASL | |---|---|---|---|---| | Storage | Git-friendly JSON files | Model + policy files/DB adapters | In-memory / your own storage | In-memory / your own storage | | Multi-tenant | Built in, folder-isolated | Manual (namespacing policies yourself) | Manual | Manual | | Node + browser | One package, both builds | Node-focused | Both, no dedicated browser API | Both | | Framework adapters | 8 built in (NestJS, Express, Fastify, Koa, React, Vue, Angular, Svelte) | None built in | None built in | Some community adapters | | Core dependencies | Zero (Core Engine) | Several | Zero | Zero | | Audit logging | Built in, JSONL + rotation | Not built in | Not built in | Not built in |

Install

npm install rbac-fs

Quick start (JavaScript)

import { RBAC } from 'rbac-fs';

const rbac = new RBAC({ tenantId: 'acme-corp' });
await rbac.createRole('manager', { permissions: [{ resource: 'invoice', actions: ['approve'] }] });

const allowed = await rbac.can({ id: 'u1', role: 'manager' }, 'invoice', 'approve');
console.log(allowed); // true

Quick start (TypeScript)

import { RBAC, type RbacUser } from 'rbac-fs';

const rbac = new RBAC({ tenantId: 'acme-corp' });
await rbac.createRole('manager', { permissions: [{ resource: 'invoice', actions: ['approve'] }] });

const user: RbacUser = { id: 'u1', role: 'manager' };
const allowed: boolean = await rbac.can(user, 'invoice', 'approve');

Both snippets create .rbac/tenants/acme-corp/roles/manager.json on disk the first time they run — that file is the reviewable source of truth from then on; hand-editing it works too (and is picked up automatically, see "Live-reload" below).

Multi-tenant example

const acme = new RBAC({ tenantId: 'acme-corp' });
const globex = new RBAC({ tenantId: 'globex-inc' });

await acme.createRole('manager', { permissions: [{ resource: 'invoice', actions: ['approve'] }] });
await globex.createRole('manager', { permissions: [{ resource: 'ledger', actions: ['approve'] }] });

// Same role name, completely isolated files and permissions per tenant:
// .rbac/tenants/acme-corp/roles/manager.json
// .rbac/tenants/globex-inc/roles/manager.json

// Omit tenantId (or pass null) to use cross-tenant `_shared/` roles instead:
const platform = new RBAC(); // -> .rbac/_shared/roles/*.json

tenantId and role names are sanitized against ^[a-zA-Z0-9_-]+$ before touching the filesystem — path-traversal attempts (../../etc, etc.) are rejected, not silently resolved.

Audit logging + rotation

Every can() call is recorded to logs/<role>.jsonl (JSON Lines — one allow/deny decision per line, so a corrupted line only breaks that one record). Rotation is on by default and configurable:

const rbac = new RBAC({
  tenantId: 'acme-corp',
  rotation: {
    maxSize: '5MB',   // rotate the active log once it reaches this size
    maxAge: '90d',     // delete rotated files older than this
    compress: 'gzip',  // compress rotated files
    maxBackups: 12,    // keep at most this many rotated files per role
  },
});

const entries = await rbac.getAuditLog('manager', { since: '2026-08-01' });

Live-reload

Role files are cached in memory for fast can() checks, and a chokidar-backed watcher invalidates the cache automatically when a role file is hand-edited on disk — no restart needed. Disable caching entirely with new LocalJsonAdapter({ cache: false }) if you're on a filesystem where local file-watching isn't reliable (e.g. some networked/shared volumes).

Dynamic role management

await rbac.createRole('supervisor', { inherits: ['viewer'] });
await rbac.grant('supervisor', { resource: 'invoice', actions: ['view', 'approve'] });
await rbac.revoke('supervisor', { resource: 'invoice', actions: ['approve'] });
await rbac.listRoles();
await rbac.deleteRole('supervisor');

Feature-scoped permissions + composable conditions

A role's permission matrix can have more than one layer: module-level grants, feature-level grants within a module, and a runtime condition scoped to one specific feature.

Module vs. feature — just separate resource ids

resource is a free-form string with no built-in hierarchy — a feature is simply its own resource id, dot-separated by convention. Granting the module does not automatically grant its features (no wildcard rollup):

await rbac.grant('clerk', { resource: 'invoice', actions: ['view'] }); // module-level
await rbac.grant('clerk', { resource: 'invoice.line-items', actions: ['add', 'edit'] }); // feature-level

await rbac.can(clerkUser, 'invoice', 'view'); // true
await rbac.can(clerkUser, 'invoice.line-items', 'add'); // true
await rbac.can(clerkUser, 'invoice', 'add'); // false — module grant doesn't imply the feature

Composable condition — AND/OR/NOT over a fixed, safe operator set

A single when: "a == b" clause can't express "device is mobile and location is in this list" — multiple Condition entries on the same resource+action OR against each other, not AND. The condition tree solves this with and/or/not nodes, nestable to arbitrary depth, over a fixed operator vocabulary — still zero eval()/Function(), just JSON:

await rbac.createRole('mobile-approver', {
  conditions: [
    {
      resource: 'invoice.line-items',
      actions: ['approve'],
      condition: {
        and: [
          { op: 'eq', path: 'device', value: 'mobile' },
          { op: 'in', path: 'location', value: ['US', 'IN', 'EU'] },
        ],
      },
    },
  ],
});

await rbac.can(user, 'invoice.line-items', 'approve', { device: 'mobile', location: 'IN' }); // true
await rbac.can(user, 'invoice.line-items', 'approve', { device: 'desktop', location: 'IN' }); // false

path/valuePath operands resolve the same way when always has: user.<field> reads from the user object passed to can(), any other bare path reads from context (can()'s 4th argument).

Operators:

| Operator | Leaf shape | Meaning | |---|---|---| | eq / neq | { op, path, value } or { op, path, valuePath } | equal / not equal (loose string comparison, same as legacy when) | | gt / gte / lt / lte | { op, path, value: number } or { op, path, valuePath } | numeric comparison | | in / notIn | { op, path, value: [...] } | membership in a literal list | | exists / notExists | { op, path } | is the resolved value defined? | | contains | { op, path, value } | substring match on a string, or membership on an array | | startsWith / endsWith | { op, path, value } | string prefix/suffix match |

when still works, unchanged — a Condition uses exactly one of when (legacy single-clause form) or condition (the tree); both evaluate through the same evaluator, no migration needed for existing role files.

Custom operators — the escape hatch for rules the built-ins can't express

For app-specific logic no fixed operator list can anticipate (geofence radius, business-hours windows, a scoring function), register a named predicate — the engine calls a real function you wrote, never anything parsed out of the JSON role file:

const rbac = new RBAC({
  tenantId: 'acme-corp',
  operators: {
    withinRadius: ({ context, args }) => haversineKm(context.userLocation, context.siteLocation) <= (args?.km as number),
  },
});

await rbac.createRole('field-approver', {
  conditions: [{ resource: 'site-visit', actions: ['approve'], condition: { op: 'custom', name: 'withinRadius', args: { km: 5 } } }],
});

Same option on the browser client: new RBACClient(snapshot, { operators }). Referencing an unregistered custom name throws UnknownConditionOperatorError — deliberately, not a silent deny.

Browser usage

The Node package (rbac-fs) reads/writes .rbac/ on disk and should never ship to a browser bundle. For the browser, fetch an already-resolved permission snapshot from your backend and use the read-only client instead — synchronous, in-memory, zero filesystem access:

import { RBACClient } from 'rbac-fs/client';

const client = new RBACClient(snapshotFromYourApi); // { user?, permissions, conditions? }
client.can('invoice', 'approve'); // synchronous, no await

RBACClient has no close() — unlike the Node RBAC class, it holds no OS resources (no file watchers, no log streams), so there's nothing to release.

Framework adapters

Every adapter is a subpath of the same package — one npm install, pick what you need:

| Import | Framework | What it gives you | |---|---|---| | rbac-fs | — (Node core) | RBAC class, full read/write, the entry point every backend adapter wraps | | rbac-fs/client | — (browser core) | RBACClient, synchronous read-only can() from a snapshot | | rbac-fs/express | Express | rbacMiddleware(rbac, resource, action, options?) | | rbac-fs/nestjs | NestJS | @RequirePermission() decorator + RbacGuard + provideRbac() | | rbac-fs/fastify | Fastify | rbacPlugin — register once, declare config: { rbac: { resource, action } } per route | | rbac-fs/koa | Koa | rbacMiddleware(rbac, resource, action, options?), async/await native | | rbac-fs/react | React | <RbacProvider> + <Can I="approve" a="invoice"> + usePermission() | | rbac-fs/vue | Vue 3 | createRbacPlugin(client) + v-can directive + usePermission() composable | | rbac-fs/angular | Angular | provideRbacClient() + RbacService + *rbacCan structural directive | | rbac-fs/svelte | Svelte | createPermissionStore(client) ($permissions(...)) + createCanAction(client) (use:can) |

Every adapter is a thin translation layer — none of them re-implement permission logic; they all call straight into RBAC.can() / RBACClient.can(), so the same role/permission files drive every framework identically.

See examples/ for a runnable, verified-against-the-real-build code sample for every one of the above — core engine, dynamic roles, conditional grants, multi-tenancy, audit logging, live-reload, and all 8 framework adapters.

Security guardrails (built in, not left to you)

  • tenantId/role name sanitization against path traversal, on every call.
  • Schema validation on every role write (unknown fields, malformed permissions/conditions all rejected before touching disk).
  • Reserved role names (admin, system-admin) blocked from accidental overwrite unless { force: true }.
  • Circular inheritance detection on every createRole/grant.
  • Condition expressions (legacy when clauses and the composable condition tree — see "Feature-scoped permissions + composable conditions") use a hand-rolled, fixed operator vocabulary — no eval()/Function() — so a hand-edited role file can't become a code-execution vector. custom operators call a function you registered in your own code, never anything parsed out of the file.

Who's allowed to call createRole/grant/etc. in the first place is your app's own business rule — check rbac.can(user, 'role', 'manage') (or whatever permission model fits your app) before exposing role management to end users.

Minimum requirements

Node.js >=20. TypeScript is optional — every subpath ships plain .js (CJS + ESM) with bundled .d.ts types; a .js-only project never needs a TypeScript compiler.

License

MIT