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

@kilnjs/permissions

v0.1.3

Published

A capability vocabulary, roles, grants and request boundaries for KilnJS, as a module core is given rather than one it contains

Readme

@kilnjs/permissions

A capability vocabulary, roles, grants and request boundaries for KilnJS, packaged as a module core is given rather than one it contains. A server that does not declare it boots without any of this.

Install

yarn add @kilnjs/permissions
// app.config.ts
import type { AppConfig } from "@kilnjs/core/application";
import { permissions } from "@kilnjs/permissions";

export default {
  modules: [permissions()],
} satisfies AppConfig;

That is the whole installation, and it takes no arguments.

It works the way a Fastify plugin does: declaring it decorates the server and widens the types that describe it, so nothing in an application has to be re-imported or re-exported to see the difference.

| It adds | Where | | --- | --- | | server.access - the compiled vocabulary, roles and principal builder | FastifyInstance | | request.auth, request.boundaries | FastifyRequest | | authorize, isHidden on an endpoint | EndpointExtensions | | context.auth, context.boundaries | ContextExtensions |

Those four declarations live beside the plugin in src/module.ts, the way a Fastify plugin declares its own decorations. An application writes none of its own: an endpoint keeps importing Endpoint from @kilnjs/core/endpoints and simply has authorize once this module is declared.

The module also adds the authorization hook to every non-public route and refuses the boot on an endpoint that declares neither authorize nor isPublic.

The policy is discovered

One module: src/permissions/index.ts, exporting the policy as its default. How the application splits it up behind that is the application's business - the framework reads only what comes out of this one file.

// src/permissions/index.ts
import { definePolicy } from "@kilnjs/permissions";

import ancestry from "./ancestry";
import boundaries from "./boundaries";
import caller from "./caller";
import { RESOURCES } from "./resources";
import roles, { base, requires } from "./roles";

export default definePolicy({
  resources: RESOURCES,  // what exists, and where it lives
  roles,                 // what each kind of member may do
  base,                  // the role every signed-in caller holds
  requires,              // roles that must compile, whatever else changes
  caller,                // a user to the standings they hold
  boundaries,            // optional: where a request is acting
  ancestry,              // optional: how a nesting resource is placed
});

definePolicy ties the pieces to each other, so most mistakes never reach a boot. base and every entry in requires must name a role declared in the same object:

error TS2322: Type '"NOSUCH"' is not assignable to type '"STANDARD" | "ADMIN" | "OWNER"'
error TS2345: Property 'caller' is missing in type ... but required in type 'PermissionPolicy'

Adding a value to the database's Role enum without defining it stops compiling, rather than being a role that silently confers nothing.

What is left is compiled during boot, before any route is mounted, so anything that could never be honoured stops the process with the reason:

✖ Invalid role definition: "sessions:read" names the unknown resource "sessions"
✖ No assignable role grants billing:settle, so nothing but an unrestricted
  caller could ever use it
✖ The permission module found no policy. Export one as default from
  src/permissions/index.ts, built with definePolicy

permissions({ policy }) takes one directly, for a test that wants a vocabulary the application does not have.

The model

You declare what exists, what may be done to it, and where it lives:

export const RESOURCES = {
  spaces: {
    actions: ["read", "write", "delete", "manage", "create"],
    within: ["spaces", "org"],
  },
  documents: { actions: ["read", "write", "delete", "create"], within: "spaces" },
  org: { actions: ["read", "manage"] },
} as const;

Two expansions do the rest, and both are declared rather than assumed:

export const RESOURCES = {
  spaces: {
    actions: ["read", "write", "delete", "manage", "create"],
    within: ["spaces", "org"],

    // What one action says about the others, on this resource
    implies: { manage: ["write", "delete", "read"], write: ["read"] },

    // Whether a capability here reaches what nests inside it
    cascades: true,
  },
} as const;
  • implies widens an action, transitively, within the resource that declared it. STANDARD_IMPLICATIONS is the usual table, offered to spread rather than applied: implies: { ...STANDARD_IMPLICATIONS, manage: [...] }.
  • cascades reaches downward. A capability on a space then applies to the documents in it, and to the spaces beneath it, as far as each one cascades. Off unless declared.

Both are per resource because the answer is per resource. A framework-wide implication table cannot know that a task may be completed, and the one that used to be here knew about write and read: a role granting tasks:manage did not confer tasks:complete while appearing to confer everything. An implication that confers an action the resource does not declare is a boot error rather than a line that does nothing.

A cascade carries only the actions the parent itself declares. org:manage cannot confer documents:write when org has no write, however deep the nesting goes.

Roles name a resource and an action, never an instance:

export const ROLE_DEFINITIONS = {
  STANDARD: { grants: ["spaces:create", "documents:create"] },
  ADMIN: { inherits: ["STANDARD"], grants: ["spaces:manage"] },
  OWNER: { inherits: ["ADMIN"], grants: ["org:manage"] },
} satisfies Record<string, RoleDefinition<PermissionString>>;

Refusals are what the absence of a grant cannot say. A role may forbid, and a refusal beats every grant: one inherited, one conferred by a wildcard, one held by a caller who holds everything. "Suspended" and "under legal hold" are decisions that have to beat a grant rather than wait for one to be withdrawn.

READ_ONLY: { inherits: ["ADMIN"], grants: [], forbids: ["spaces:write"] },
SUSPENDED: { inherits: ["ADMIN"], grants: [], forbids: ["spaces:*"] },

A refusal refuses exactly what it names and nothing it implies: taking away manage is not a reason to take away write. The wildcard is how a role says "none of it". A refusal on the base role has no standing, so it survives every boundary, which is the only way to say "nobody, ever".

Grants are how a capability gets pinned to one instance: a stored row, read at the same time as the caller, widened by exactly the two expansions above.

Boundaries are where a request is acting. The principal is confined to them before anything is asked of it, so capabilities held elsewhere are gone rather than filtered out at each check. A principal refuses to be granted anything once bound.

Everything is compiled when the process starts, so a role naming an unknown resource, an action a resource never declared, an inheritance cycle, or a capability no assignable role could ever confer all stop the boot.

In an endpoint

// A capability wherever the request is acting
handler.authorize = [Auth.Check("spaces:create")];

// A capability on the instance the request names, keyed by the resource whose
// ids it reads. The chain above that instance is resolved through your
// ancestry resolver, so a role held on an ancestor reaches down to it
handler.authorize = [
  Auth.Check("spaces:read", { spaces: (req) => req.params.id }),
];

// Something that has to be true beyond who is asking. `describe` is not
// optional: it is what the printed policy says about this route
handler.authorize = [
  Auth.Check(
    "spaces:write",
    { spaces: (req) => req.params.id },
    { when: { describe: "the space is not archived", holds: isOpen } },
  ),
];

// A list has no instance to target, so the query filters instead
handler.authorize = [Auth.Null("Returns only what the caller reaches")];

The target is keyed by the resource so the compiler can check it: Auth.Check("tasks:write", { lists: ... }) does not compile. A list id handed to a capability on tasks resolves to no task, and the check that follows is about nothing.

A target that reads nothing denies, which is the default. A renamed parameter, an optional one left out, a path that moved: each used to ask the untargeted question instead, quietly turning an instance check into a scope check on exactly the inputs nobody tests. A bulk endpoint that means it says so:

handler.authorize = [
  Auth.Check(
    "things:write",
    { things: (req) => req.body.ids },
    { onMissingTarget: "scope" },
  ),
];

Listing by reach

getScopeFromRequest(capability) reads the principal off the request context, so a model method can filter by the same rules a check would apply without anyone threading a principal through:

const reach = getScopeFromRequest("things:read");

const things = reach.everywhere
  ? await database.thing.findMany()
  : await database.thing.findMany({ where: { id: { in: reach.at.things } } });

isInstanceWithinReach(reach, "things", id) asks the same question about one instance, which is what a single read needs where a list filters.

A reach can have holes in it. forbid beats every grant, so { everywhere: true, except: { things: ["two"] } } is a real answer: reached everywhere apart from that one. Both helpers read except, and a query that reads everywhere alone has to as well.

An unrestricted caller reaches everywhere. markUnrestricted() is what says so, and it holds no rules at all, so reading the rules answered { everywhere: false, at: {} }: a list filtered by it showed the one caller who may see everything nothing whatsoever, while every check that same caller made passed. A check and a list disagreeing that way is an access bug nobody reads as one, because nothing refuses and nothing appears.

kiln permissions

The compiled policy, in sentences, and the check that each one is true. The paths it prints are the ones the router serves, which means they carry the prefix a module mounts every controller under: with @kilnjs/next installed, POST /api/lists/:id/archive rather than POST /lists/:id/archive. It used to print the controller's own prefix alone, so a report whose whole purpose is to be read against the running server named paths nothing served.

--ceiling=N raises how many roles are still enumerated exhaustively: above twelve the proof samples instead of taking every subset, and says which it did.

POST /lists/:id/archive
  Any caller with lists:manage [ADMIN] on the lists it names can reach
  Lists/archive. A caller who holds everything can too.

POST /tasks/:id/complete
  Any caller with tasks:complete [ADMIN or MEMBER] on the tasks it names, and
  only when the list is not archived, can reach Tasks/complete.

Then each role against everything it effectively holds, which is where a cascade becomes readable, and two coverage diffs: capabilities no route asks for, and capabilities a route asks for that no role grants. The second one is a route nobody can reach, and it is the gap that is hardest to see by reading the policy.

Then the routes that ask for nothing, gathered rather than left among the rest: a public route is reachable with no session and one stating a reason is reachable by every signed-in caller. Both are sometimes right and neither should be found by reading past twelve routes that are guarded.

It ends with the part that makes the sentences worth printing:

PROOF

  304 caller points checked against the engine, over every caller this policy
  can tell apart.
  Every sentence above is exactly what the engine does.

A policy is finite. A fixed set of roles, a fixed vocabulary, a fixed set of capabilities per endpoint, so the set of callers it can tell apart is finite and small: which roles they hold where they are acting, whether they hold everything, whether the instance they named is inside the boundary, and whether the endpoint's own condition holds. The command enumerates that set, asks the real authorizeRequest for each point, computes what the sentence claims for the same point, and reports every disagreement.

Over a finite space that was fully enumerated, agreement at every point is not evidence of equivalence. It is equivalence. Which is worth being exact about:

  • It covers the capability logic in full: inheritance, implications, the cascade, wildcards, refusals, the base role, boundary confinement, and everything an endpoint declares.
  • It models two inputs rather than enumerating them, because both depend on the world rather than on the policy. An instance is modelled as inside the boundary, outside it, or absent. A condition is modelled as holding or not, and the sentence names it in words, so what is proved is the sentence including those words.
  • It says nothing about whether your ancestry resolver places a row correctly, which is a question about your own queries.

Everything it runs goes through the same function a request does. A verifier that re-implemented the decision would prove that two copies of the logic agree, which is not the claim.

kiln check runs it, so a sentence that stops being true fails the build. --tuples prints the policy as relation tuples, which is the shape a Zanzibar-style store holds: keeping the policy exportable is what makes moving to one a backfill rather than a rewrite.

What the declaration now says instead of the handler

Five things an engineer had to know are declarations. Each came out of building a second application on this, and each had been a comment or a paragraph before.

A target may name the place, not only the thing. A capability is held at a place and a create has no instance of its own: a post that does not exist yet has no id, so the only check available was the untargeted question plus an assertion written by hand in every such handler. Six endpoints in one application carried that assertion and nothing checked that they had.

handler.authorize = [
  Auth.Check("posts:write", { community: (req) => req.body.community }),
];

The key may be the capability's own resource or any place it nests within, and nothing else: Auth.Check("posts:write", { users: ... }) does not compile.

A target naming a place means that place. This was a cross-tenant hole and it was reachable from a real application through the documented API. When a target resolved to no ancestor chain the address fell back to where the request was acting, so asking "may I manage community B" while acting in community A produced the address [B, A]: the boundary check found A in it, and the rule held at A answered about B. An admin of any community could rename any other, and promote themselves inside it, by naming it in the path. Both answered 200. A target that is itself a place is now addressed by itself alone.

A cascade names actions. It was cascades: true on the resource, and an application declared it on the one everything else nests inside: every member had community:read, reports nest within community, so the flag handed every member the moderation queue. Nothing warned, because nothing was wrong. Now it is cascades: ["moderate"], and an action read out loud is a sentence somebody can disagree with.

A staff-only route says so. The engine refuses a declared capability no role grants, which is right: such a capability is an implicit staff check wearing a capability's clothes. That left those endpoints checking user.is_staff in a handler and made them the only routes the proof could not cover. Auth.Unrestricted("why") is the same check where the proof reads it.

Acting nowhere is a state or an error, and the policy says which. actingNowhere: "refuse" is right for a tenanted application. "base" is right where some capabilities are genuinely global: a social network's new account has joined nothing and still holds its own profile and its own notifications, both of which are on the base role, which is held everywhere because it is held at nothing.

And the boundary resolver is a shape rather than forty lines. Three applications wrote the same forty, with accidental differences, and one of those lines decides whether a caller may name a place they hold nothing at:

export default buildBoundaryFromHeader({
  header: "x-community",
  resource: "community",
  holds: (req) => req.user.memberships.map((held) => held.community_id),
  isUnrestricted: (req) => req.user.is_staff,
  remember: (req, id) => {
    req.communityId = id;
  },
});

A front end can send that header. @kilnjs/next's provider takes headers, so a page naming its place is <KilnProvider headers={() => ({ "x-inbox": inbox })}>. The client it generates used to send content-type and nothing else, with no option anywhere for a header, so an application built from these two packages could not name a place the documented way at all. Reading the place out of the path with actsIn is the other way, and needs no header.

What the printed policy says that nothing else can

kiln permissions prints each role against what it effectively holds, and each capability now says where it came from:

  MEMBER
    comments:read (implied), comments:write, community:read,
    reports:read (cascaded), reports:write, ...

(cascaded) is the word that matters. It was what the over-granting cascade looked like, and reading the effective list was how it was found: a capability against MEMBER that no role grants MEMBER. Without the marker, working out why meant reading the vocabulary.

It also reports a capability on the base role that no caller can use. The base role is held everywhere because it is held at nothing, so a global capability belongs on it, and under actingNowhere: "refuse" a caller with no standing never reaches a route at all. Both declarations read as sensible alone and contradict each other, which is the wall a social network ran into: a new account could not read its own notifications until it had joined something.

Every decision, where you want it

watchAuthorization((decision) => {
  if (decision.allowed) return;

  // capability, targets, boundaries, and which half of the rule refused
  record(decision);
});

Called for every decision, allowed or refused. Nothing is recorded by default, because where a decision goes is the application's: a module that chose for it would be writing into somebody else's table.

permissions({ onDecision }) is the same seam declared rather than imported for its side effect, and it is registered when the server is set up and dropped when it closes, so two servers in one process do not report each other's decisions.

What permissions() takes

| | | | --- | --- | | policy | the policy, where it should not be discovered | | denyByDefault | true. The boot check behind the lint rule | | hidden | false. Whether a refused request is answered 404 rather than 403, for every guarded route at once | | onDecision | every decision, as watchAuthorization would receive it |

hidden is the module-wide form of handler.isHidden, and an endpoint's own declaration still wins: a server whose whole surface should deny that a route exists says it once here rather than on every handler.

Where the types come from

Nothing. Capability is read from your own src/permissions/resources.ts, through the same convention discoverPolicy uses at runtime, so a typo is a compile error with nothing registered and nothing augmented:

error TS2345: Argument of type '"users:raed"' is not assignable to parameter
              of type 'Capability'
error TS2322: Type '"sessions:read"' is not assignable to type 'PermissionString'

It reads @/permissions/resources, which is the convention core documents: @/ is the source root, so @/cache/auth is src/cache/auth. The emitted declaration used to name @/src/permissions/resources, which resolves only where an application maps @/ to its project root: in a project create-kiln wrote, that is src/src/permissions/resources, which exists nowhere.

And a declaration file that fails to resolve says nothing, because every application has skipLibCheck on. Capability quietly widened to string, every spelling compiled, and the guarantee above was gone with no error anywhere to say so. That is why the proof of it lives in an application's suite as a .ts file (packages/integration/tests/vocabulary.test.ts): a .d.ts is exactly what skipLibCheck skips, so assertions written in one check nothing.

The target function in Auth.Check("users:write", (req) => req.params.id) receives a request typed by that endpoint's own schema, for the same reason.

A target that comes back with nothing - an empty list, or a field that was not on the request - asks the untargeted question instead: does this caller hold the capability where they are acting? A bulk endpoint handed an empty list goes through to a handler with nothing to do, and an endpoint whose target reads a parameter that is no longer there is still a guarded endpoint. Passing outright on an empty target would be deny by default inverted on exactly the inputs nobody tests.

A capability on a nesting resource needs ancestry before a target of it can be answered: things:write is held in a tenant, so a check about one thing has to know which tenant that thing is in. Only the application knows the column, so the check is refused rather than guessed when the resolver is missing.

The lint rules

There is nothing to spread and nothing to import. kiln() turns them on itself where this module is installed, read from the application's own manifest, so an application running it is held to them and one that is not is never refused over a field it has no way to declare:

| | | | --- | --- | | kiln/require-authorize | an endpoint declares something, or says it is public | | kiln/no-public | public needs a stated reason |

The first is on only where this package is a dependency. The second is on for every controller, because an anonymous route is worth a sentence whoever is enforcing it.

Core's config also keeps src/permissions/{resources,roles}.ts away from the database: a policy declares what exists, not how to find one.

The module refuses the boot on the same condition as the first rule, but lint says so while you are still typing. Pass denyByDefault: false to drop the boot check.

The two files only you can write

// src/permissions/caller.ts - where this caller stands, and as what
export default function caller(user: AuthenticatedUser): Caller {
  return {
    unrestricted: user.is_staff,
    holds: user.memberships.map((held) => ({
      at: { resource: "org", id: held.org_id },
      role: held.role,
      grants: held.permissions.map(...),
    })),
  };
}
// src/permissions/boundaries.ts - where a request is acting, outermost first
export default async function getBoundaries(req) {
  return [{ ref: { resource: "org", id: readHeader(req, "x-organization") } }];
}

Neither is something the package could know: the first reads your standings out of your own schema, the second decides what "here" means for your product. Everything else about the model is derived from resources.ts and roles.ts.

An application with one tenant

Where every request acts in the same one place, that place is no place at all, and both of those files used to have to pretend otherwise: an invented resource to stand on, a role granted at it, and a boundaries returning that one constant. Three files carrying no decision.

A standing may name no place, and is then held everywhere, the way the base role is:

export default function caller(user: AuthenticatedUser): Caller {
  return { unrestricted: user.is_staff, holds: [{ role: user.role }] };
}

boundaries is optional. A policy that declares none answers nowhere, which is where a standing held without a place applies, and actingNowhere then defaults to "base" rather than "refuse": refusing a request that names nowhere would refuse every request. kiln permissions read that default as "refuse" while the engine read it as "base", and so printed "a request that names nowhere is refused" about a policy that refuses nothing of the kind, inside the report whose proof says every sentence is what the engine does. Both now read it through one function.

A policy that cannot bind a place

A standing at a named place needs a boundaries that can name one, and the boot refuses the pair without it. A standing like { at: { resource: "inbox", id }, role: "AGENT" } compiles and prints as working, but a request binds the principal to where it is acting, and a policy with no boundaries binds it to nowhere: that keeps only the rules which are global in both place and target, so every placed standing is dropped and every agent, viewer and manager is refused on every route. Nothing was wrong with the roles, so the refusal read as a policy that denies by design.

It is caught the first time such a caller appears rather than at boot, because standings are per caller: caller() reads them off the user, and no static pass can know what it will return. The message names both ways out, which are to declare boundaries or to hold the standing everywhere.

A test against the engine can pass while this is broken, because principal.can(...) on an unbound principal still sees the placed rules. A test that proves a route is reachable has to go through the binding.

So a single-tenant policy is four keys and one file:

export default definePolicy({ resources: RESOURCES, roles, base, caller });

And a resource may be only a place to stand. actions: [] is legal and is what a tenant resource declares: it has no capabilities of its own, so no role needs to grant any. The refusal for a capability nothing grants says so:

✖ No assignable role grants inbox:read, so nothing but an unrestricted caller
  could ever use it. Grant it to a role, or declare the resource with
  `actions: []` where it is only a place for a role to be held

Development

yarn verify   # ts:check, lint, build, exports:check, test

The engine is pure - it reaches no database, no cache and no configuration - so its 51 tests need nothing and run in under a second.