TypeScript-native authorization · Apache-2.0 · ESM only
Stop guessing who can see what.
tsbouncer is an authorization graph that runs inside your app — one model, one set of tuples, over storage you already own. Every decision returns not just allow or deny, but the reason why.
$ npm i @tsbouncer/tsbouncer
ok a known user reads what they own 200
ok a role grants access to every document it is attached to 200
ok a viewer may read but not write 403
ok an editor may not change who has access 403
ok a grant whose row was hard-deleted is a 404 404
ok why returns the decision tree, not a guess 200
19 of 19 scenarios passedDeclare it. Ask it. Get a reason.
The whole library is three calls. The model is code, the grants are data, and the answer cites its sources.
src/model.ts
defineType('document', {
relations: {
owner: relation('user'),
editor: relation('user')
.or(relation('role', { through: 'holder' })),
},
permissions: {
read: permission.or('owner', 'editor'),
write: permission.or('owner', 'editor'),
manage: permission.or('owner'),
},
})src/verify.ts
// Bob holds the editor role. Carol only views.
await authz.can('user:bob', 'document.read', 'document:1')
// → true — editor, through role:acme:editor#holder
await authz.can('user:carol', 'document.write', 'document:1')
// → false — a viewer may read but not write
await authz.explain({ subject: 'user:carol',
permission: 'document.write', resource: 'document:1' })
// → the tree: which edges held, which missed,
// and how many store reads it tookWhy teams pick it up
Each of these is a checked guarantee, not a slogan — the contract report runs against a real store on every build.
In-process, not over the wire
The evaluator runs inside your request handler against storage you already own. No sidecar, no extra hop, no new failure domain.
Every decision explains itself
explain() returns the tree that produced the answer — which edges held, which missed, how many reads it took. Support stops guessing.
Fail closed, everywhere
Unknown condition, missing key, thrown predicate, exhausted budget: all deny. There is no permissive fallback anywhere.
Budgets, not hope
Depth, node, and deadline ceilings per request. Exhaustion denies and reports truncated, so a partial answer never looks complete.
Lists that refuse to invent
A wildcard grant reports a symbolic set, not a fabricated roster. listResources always says whether its answer is whole.
Conditions as code
Predicates are TypeScript functions beside your domain logic. Tuple bindings win; the request only fills the gaps.
Coming from Google Zanzibar or OpenFGA? Readthe comparison catalog — including the rows where this library is the worse answer.