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

@prefabs.tech/access-control

v0.2.0

Published

Attribute-based permission checks for the browser and Node, with no dependencies

Readme

@prefabs.tech/access-control

Attribute-based permission checks in a single TypeScript file. It runs unchanged in the browser and in Node, and has no dependencies.

Installation

npm install @prefabs.tech/access-control

The package ships an ES module and a CommonJS build, each with TypeScript declarations, so it loads with import or require(), including under Jest and with TypeScript's older moduleResolution: "node". It needs Node 22 or later.

Usage

You give it the list of grants a user holds and the permission you want to test, and it answers yes or no:

import { checkPermission } from "@prefabs.tech/access-control";

const grants = ["admin.user.*", "!admin.user.delete"];

checkPermission(grants, "admin.user.invite"); // true
checkPermission(grants, "admin.user.delete"); // false
checkPermission(grants, "reports.view");      // false

checkPermission(grants, permission, context?) takes:

  • grants: what the user holds, built by your app from their roles and any grants given to them directly. Grants may use patterns (admin.*), denies (!admin.user.delete), conditions and sources.
  • permission: the one concrete name being checked, such as admin.user.delete. Never a pattern or a deny. To ask about a whole area, see Any permission under a heading.
  • context: the object being acted on, for grants with conditions (see Conditions).

The first argument is often called "the user's permissions". This README calls its entries grants, to keep them apart from the name being checked. A storage layer may still keep grants in a table called permissions.

The library only evaluates grants. Where they are stored, how they are attached to users or roles, and how they reach the browser is up to you.

Terms

| Term | What it is | Where it lives | | --- | --- | --- | | Permission | The concrete name being checked, such as invoice.view | The check | | Grant | What a user holds: a permission pattern, maybe a deny, conditions and a source | Stored with a role or the user | | Conditions | The rule on a grant, such as { orgId: 42 }: which objects it applies to | The grant, written once | | Context | The object being acted on, such as the invoice | The check, passed each time |

A grant with conditions applies when the context matches them: { permission: "invoice.view", conditions: { orgId: 42 } } allows checkPermission(grants, "invoice.view", invoice) when invoice.orgId is 42.

Attribute-based access control

A grant can be limited to certain objects by giving it conditions. To check such a grant, pass the object being acted on as the third argument of checkPermission, the context. The grant applies only when the context matches its conditions.

import { type Grant, checkPermission } from "@prefabs.tech/access-control";

const grants: Grant[] = [
  { permission: "invoice.view", conditions: { orgId: 42 } },
  { permission: "invoice.edit", conditions: { orgId: 42, status: ["draft", "pending"] } },
];

const invoice = { id: 9, orgId: 42, status: "paid" };

checkPermission(grants, "invoice.view", invoice);           // true: same organisation
checkPermission(grants, "invoice.edit", invoice);           // false: paid invoices can't be edited
checkPermission(grants, "invoice.view", { orgId: 7 });      // false: another organisation

The context is any plain object, and it can be nested ({ org: { id: 42 } } is matched by the condition key 'org.id'). Grants without conditions ignore it.

Only the user's own records

To limit a grant to objects that belong to the current user, write it once on the role with a placeholder for the user's id:

const editor = [{ permission: "post.edit", conditions: { userId: "$user.id" } }];

When you build the user's permissions (at login, say), bindGrants replaces the placeholder with that user's actual id:

const grants = bindGrants(editor, { user: currentUser }); // currentUser.id is 7
// [{ permission: "post.edit", conditions: { userId: 7 } }]

checkPermission(grants, "post.edit", { id: 1, userId: 7 });  // true: their own post
checkPermission(grants, "post.edit", { id: 2, userId: 8 });  // false: someone else's
checkPermission(grants, "post.edit", { id: 3 });             // false: no userId at all

The bound grants are still plain data, so they can be sent to the browser and the same check can run there. Make sure currentUser.id is set before binding: a missing value becomes null, and the grant would then match objects whose userId is null.

Leaving the context out asks a looser question, "could they do this on some object?", which is meant for showing or hiding parts of the user interface. On the back end, always pass the real object. See Conditions for everything a condition can express and Checking with and without a context for the exact rules.

Permissions and grants

A permission is a dot-separated name such as admin.user.invite. An empty name, or one with an empty segment (admin..view), is invalid and is always refused.

* and a leading ! are grant syntax, described below. They must not appear in the permission being checked: there they have no meaning and are compared as plain text, so the result depends on which grants happen to match.

A grant is what a user holds. It is either a string or an object:

| Grant | Meaning | | --- | --- | | "admin.user.view" | That exact permission, on any object | | "admin.user.*" | Everything under admin.user | | "admin.*" | Everything under admin | | "*" | Everything (superuser) | | "admin.*.view" | view on anything directly under admin | | "!admin.user.delete" | Deny | | { permission, conditions } | Allow only on objects that match the conditions | | { permission, effect: 'deny', conditions } | Deny only on objects that match the conditions | | { permission, source: 'direct' } | Allow given to the user directly, deciding before their roles (see Direct and role grants) |

Wildcards follow two rules:

  • A * at the end matches one or more segments. admin.* covers admin.user.view but not admin itself.
  • A * anywhere else matches exactly one segment. admin.*.view covers admin.user.view but not admin.user.profile.view.

Deny beats allow among grants of the same source, whatever the order of the grants. With no matching grant at all, the answer is no.

Direct and role grants

An object grant can carry a source: "role" when it comes from one of the user's roles, "direct" when it was given to the user directly. String grants and grants without one are role grants. When any direct grant matches, the direct grants decide: among them a deny beats an allow, and otherwise an allow allows. Only when no direct grant matches do the role grants decide, the same way.

So a direct grant can lift a deny that a role gives, for one person.

const grants: Grant[] = mergeGrants(
  [{ permission: "report.*" }, { permission: "report.delete", effect: "deny" }], // from a role
  [{ permission: "report.delete", source: "direct" }],                            // given directly
);

checkPermission(grants, "report.delete", {}); // true: the direct allow decides

| Role | Direct | Answer | | --- | --- | --- | | allow | deny | deny | | deny | allow | allow | | nothing | allow | allow | | nothing | deny | deny | | allow | nothing | allow | | deny | nothing | deny | | nothing | nothing | deny |

A grant "matches" as everywhere else in this README: its pattern covers the permission, and with a context its conditions hold. Without a context, a conditional allow matches and a conditional deny is ignored. A condition that throws doesn't match on an allow, so for a direct grant the roles decide, and matches on a deny, which then denies. A broad direct grant decides over a narrow role grant: a direct report.* lifts a role's deny of report.delete.

A list without any source answers exactly as before sources existed.

Conditions

A condition restricts a grant to certain objects. The object is described by the context you pass as the third argument to checkPermission.

Attribute conditions

A plain object. Every key must match.

const grants = [
  { permission: "admin.user.view", conditions: { orgId: [1, 2], "team.id": "ops" } },
];

checkPermission(grants, "admin.user.view", { orgId: 2, team: { id: "ops" } });   // true
checkPermission(grants, "admin.user.view", { orgId: "1", team: { id: "ops" } }); // true
checkPermission(grants, "admin.user.view", { orgId: 3, team: { id: "ops" } });   // false
checkPermission(grants, "admin.user.view", { orgId: 1 });                        // false
  • A key can be a dot path (team.id) into the context.
  • A scalar value means equality; an array means "one of".
  • Numbers and numeric strings compare equal (1 matches "1"), because IDs often arrive as strings from routes and query parameters.
  • An attribute that is missing from the context never matches.

Function conditions

A function that receives the context and returns a boolean, for logic an attribute object cannot express.

const grants: Grant[] = [
  { permission: "admin.user.view", conditions: (ctx) => ctx.userId === 123456 || ctx.orgId === 2 },
];

checkPermission(grants, "admin.user.view", { userId: 1, orgId: 2 }); // true
checkPermission(grants, "admin.user.view", { userId: 1, orgId: 4 }); // false
  • Only a strict true counts as a match; other truthy values do not.
  • If the function throws an error, for example because the context is missing a property it reads, the check errs on the side of refusing access. On an allow grant, the error counts as "no match", so the grant does not give access. On a deny grant, the error counts as "match", so access is denied.

Conditional deny

const grants: Grant[] = [
  "admin.user.*",
  { permission: "admin.user.delete", effect: "deny", conditions: { role: "owner" } },
];

checkPermission(grants, "admin.user.delete", { role: "member" }); // true
checkPermission(grants, "admin.user.delete", { role: "owner" });  // false

A deny whose conditions is {} has no conditions, so it denies everywhere, like a deny without conditions.

Checking with and without a context

Whether you pass a context changes the question being asked.

| Call | Question | Conditional allows | Conditional denies | | --- | --- | --- | --- | | checkPermission(grants, p, object) | Can they do this on this object? | Must match | Apply when they match | | checkPermission(grants, p) | Could they do this on some object? | Count as yes | Ignored |

The form without a context is for the user interface, for example to decide whether to show a menu item (for a menu item that covers a whole area, see Any permission under a heading). On the back end, always pass the real object.

An empty object is not the same as no context: checkPermission(grants, p, {}) evaluates the conditions against an object with no attributes, so conditional allows fail.

Any permission under a heading

A menu item often leads to a whole area, such as Configuration, rather than to one permission. checkPermissionUnder(grants, heading, context?) answers "can they do anything under this heading?". The heading is written as a grant covering the area would be, a name and .*, and the result is true when at least one permission it covers would pass checkPermission.

import { checkPermissionUnder } from "@prefabs.tech/access-control";

const grants = ["configuration.client.view", "!configuration.client.delete"];

checkPermissionUnder(grants, "configuration.*");                      // true: configuration.client.view
checkPermissionUnder(grants, "reporting.*");                          // false: nothing under it
checkPermissionUnder(["*", "!configuration.*"], "configuration.*");   // false: all of it is denied
  • Under, not the heading itself. The trailing * matches one or more segments, as in a grant, so a grant of exactly configuration doesn't count for configuration.*.
  • Denies and sources count as in checkPermission. A deny covering the whole heading makes it false; a deny of part of it (!configuration.client.delete) leaves the rest. A direct grant decides before the roles.
  • The context works as in checkPermission. Without one, conditional allows count as yes and conditional denies are ignored, which suits a menu. With one, conditions are evaluated against it.
  • Only a name and .*. checkPermissionUnder(grants, "configuration") is false and reported as an invalid permission, so a heading can't be mistaken for a permission, nor a permission for a heading. A * anywhere else (*.view, configuration.*.edit, *) and a leading ! are refused the same way.

On the back end, check the concrete permission of the action instead: a heading says only that something under it is allowed.

Malformed input

checkPermission never throws, so it can be called anywhere without a try/catch. Whatever it cannot make sense of, it refuses:

  • grants that is not an array. null, undefined and [] are refused too, as no grants at all.
  • A permission being checked that is not a valid name, such as null, a number or "admin..view".
  • A grant that is not a string or an object with a string permission, such as null, a number, or a hole in a sparse array.
  • A grant whose effect is anything but "allow" or "deny", such as "Deny" or null. Otherwise a misspelt deny would count as an allow.
  • A grant whose source is anything but "role" or "direct", such as "user", "Direct" or null.
  • A grant whose conditions is not a function or a plain object, such as 5, [], true, a Date or a class instance (these would otherwise match every object), or a string: a named condition that resolveGrants has not replaced with its function. null and {} mean no conditions. A plain object created in another realm, such as an iframe, is refused as well, since it doesn't have this realm's Object.prototype.

One malformed grant refuses the whole check, whatever the other grants say, including direct grants that would have decided. It may have been meant as a deny, and there is no telling what it covers.

Grant objects are read by their own properties only, so a value inherited from a prototype, for example after prototype pollution, is ignored. An attribute condition whose context throws when read (a getter, a revoked proxy) fails closed like a function condition that throws.

assertPermission throws a PermissionError for all of these, never a TypeError.

These refusals are silent unless you set a logger, which is the way to find out why a check that should pass doesn't.

Logging

checkPermission and checkPermissionUnder can report what they refused and which conditions failed. Set a logger once at start-up:

import { setLogger } from "@prefabs.tech/access-control";

// In the browser
setLogger((event) => console.warn(event.message));

// On Node, for example with pino
setLogger((event) => logger.warn(event, event.message));

There is none by default. setLogger(null) turns reporting off, and anything other than a function or null throws a TypeError.

The logger receives a PermissionLogEvent. Its reason is one of:

| reason | When | The check | | --- | --- | --- | | invalid-permission | The permission being checked is not a valid name | Refused | | invalid-grants | grants is not an array (null, undefined and [] are not reported) | Refused | | invalid-grant | A grant is malformed; field says which part, when it is one | Refused | | condition-error | A grant's condition threw | That grant fails closed | | unknown-condition | A condition name is missing from the resolveGrants registry | That grant fails closed | | unexpected-error | Anything else went wrong | Refused |

The other fields, each present only when it applies: message (always; a sentence built from the other fields), permission (the permission being checked, or the heading for checkPermissionUnder), grantIndex, grant (its pattern, with ! first for a deny), field (permission, effect, conditions or source), received (the type of the bad value, such as null, array or non-plain object), condition (the unknown name) and errorName (such as TypeError).

Safe to send from a browser. An event never holds the context, a grant's conditions, or the message of an error thrown by a condition function, since any of these can carry the very data being protected. A thrown value that is not an Error is reported by its type only. Strings longer than 200 characters are cut.

  • No de-duplication. Every check reports, so a malformed grant in a list that a page checks on each render reports on each render. checkPermissionUnder may test several permissions under the heading, so one failing condition can report once for each. Throttle in the logger if that matters.
  • The logger cannot break a check. If it throws, the error is ignored, and an async logger's rejection is handled. Checks made from inside the logger do not report, so a logger that calls checkPermission cannot recurse.
  • One logger per copy of the package. The logger is held by the module. On Node 22.12 or later, import and require() load the same copy, so one call covers both. On earlier Node 22 releases, or with a bundler that resolves the two to different files, call setLogger from each.

Enforcing on the back end

assertPermission throws instead of returning false. The context is required.

import { assertPermission, PermissionError } from "@prefabs.tech/access-control";

try {
  assertPermission(user.permissions, "admin.user.delete", { userId: target.id, role: target.role });
} catch (err) {
  if (err instanceof PermissionError) {
    // err.status === 403, err.permission === "admin.user.delete"
  }
  throw err;
}

On Node 22.12 or later, import and require() load the same copy of the package, so instanceof PermissionError holds wherever the error was thrown. On earlier Node 22 releases, or with a bundler that resolves import and require() to different files, code that mixes the two gets two copies of the class; check err.name === "PermissionError" there instead.

Building the grant list

Three helpers prepare a grant list before it is checked.

mergeGrants(...lists)

Concatenates grant lists, for example from several roles plus grants given directly to the user. null and undefined lists are skipped. It keeps each grant as it is, source included; so do bindGrants and resolveGrants.

const grants = mergeGrants(roleA.permissions, roleB.permissions, user.permissions);

bindGrants(grants, vars)

Replaces $… placeholders in attribute conditions with values from vars, so a role can say "only your own records" once and each user gets concrete values.

const role = [{ permission: "profile.edit", conditions: { userId: "$user.id" } }];

const grants = bindGrants(role, { user: { id: 7 } });
// [{ permission: "profile.edit", conditions: { userId: 7 } }]

Run it on the server when building the user's permissions (at login, say), then send the bound list to the front end. String grants and function conditions are returned untouched.

A placeholder that cannot be resolved becomes null. It then matches only objects whose attribute is explicitly null, so make sure every placeholder a role uses is present in vars.

resolveGrants(stored, registry)

Functions cannot be stored or sent as JSON. A stored grant can name its condition instead, and resolveGrants swaps each name for the function in a registry. Both the server and the browser can do this with their own registry.

const stored = [{ permission: "admin.user.view", conditions: "sameOrg" }];

const grants = resolveGrants(stored, {
  sameOrg: (ctx) => ctx.orgId === currentUser.orgId,
});

A name that is not in the registry is treated like a condition that throws an error, so the check refuses access: an allow grant with an unknown name never gives access, and a deny grant with an unknown name always denies. A logger receives it as unknown-condition, with the name.

Resolve before checking. A grant whose conditions is still a string, because the list never went through resolveGrants, is malformed: it refuses the whole check, not only that grant, and a logger receives it as invalid-grant with field: "conditions".

Development

The tests use Node's built-in runner and run the TypeScript directly, so they need a Node version with type stripping enabled by default. The Node and pnpm versions used for development are pinned in mise.toml at the repository root (mise install); pnpm can also be provided by corepack from the packageManager field of the root package.json.

pnpm install
pnpm test                                               # run the tests, with a coverage report
node --test --test-name-pattern='deny beats allow' __test__/permissions.test.ts  # run one test
pnpm typecheck                                          # type-check the source and tests
pnpm build                                              # compile to dist/

pnpm publish runs the type check, the tests and the build before publishing.