Introduction
One model, one set of tuples, one evaluator — over storage your application already has.
The whole library in one file
A model says what a shape of access is. Data says who holds it. The engine is what connects them.
import { createAuthz, defineModel, defineType, permission, relation, wildcard } from '@tsbouncer/tsbouncer';
import { memoryStore } from '@tsbouncer/in-memory';
const model = defineModel({
types: {
user: defineType({}),
team: defineType({ relations: { member: relation(['user']) } }),
document: defineType({
relations: {
owner: relation(['user']),
editor: relation('user').or(relation('team', { through: 'member' })),
banned: relation('user').or(wildcard('user')),
},
permissions: {
read: permission.or('owner', 'editor'),
write: permission.allOf('owner', 'editor').except('banned'),
},
}),
},
});
const authz = createAuthz({ model, store: memoryStore() });Everything below is that same authz.
Writing data
Tuples are the access data. They are plain objects, so they serialize, diff, and seed without any special handling.
await authz.grant({ subject: 'user:alice', relation: 'owner', resource: 'document:1' });
await authz.grant({ subject: 'team:eng#member', relation: 'editor', resource: 'document:2' });
await authz.can('user:alice', 'document.read', 'document:1'); // true
await authz.can('user:bob', 'document.read', 'document:2'); // false — not a memberA tuple is rejected at write time if it names a relation the model does not declare, so a typo fails where it is still cheap rather than denying forever.
The decision
const decision = await authz.check({
subject: 'user:alice',
permission: 'document.read',
resource: 'document:1',
});
async function serve(subject: string, resource: string): Promise<Response> {
if (!decision.allowed) return new Response('forbidden', { status: 403 });
return new Response('ok');
}Or a boolean, if a denial is a normal outcome rather than a bug:
const allowed = await authz.can('user:alice', 'document.read', 'document:1');Or an exception, if the route must not serve this user at all:
await authz.assert({ subject: 'user:alice', permission: 'document.write', resource: 'document:1' });Or the reason, which is the one nobody expects to need and everybody ends up needing:
import { formatExplain } from '@tsbouncer/tsbouncer';
const result = await authz.explain({
subject: 'user:bob',
permission: 'document.read',
resource: 'document:2',
});
if (!result.allowed) console.log(formatExplain(result));DENIED user:bob -> document:2#document.read
- union
- owner
- direct
no matching tuples
- editor
- union
- direct
no matching tuples
- userset
- direct
no matching tuples
(4 reads)Every leaf cites either the tuples that produced it or the query that came back empty. The tree is plain JSON, so the same call can feed a support UI, a test, or a log line.
What it is not
This is the part that matters more than the feature list. tsbouncer provides
no framework middleware, no HTTP layer, no authentication, no
sessions, and no JWT or OAuth handling.
Your application owns the request lifecycle, the transaction, and the business
logic. tsbouncer owns authorization semantics and access to authorization data.
Where to go next
- Getting started — install it and answer one question.
- The model — every shape the evaluator understands.
- Stores — choosing one, and what the contract actually says.
- Recipes — RBAC, ReBAC, ABAC, multi-tenancy, and wiring it into a real app.
- Guarantees — the claims, checked on every build.
- Comparison — against Zanzibar and OpenFGA, including the rows where this library is the worse answer.