Skip to content
tsbouncerpreview
preview
guide

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.

TypeScript
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.

TypeScript
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 member

A 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

TypeScript
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:

TypeScript
const allowed = await authz.can('user:alice', 'document.read', 'document:1');

Or an exception, if the route must not serve this user at all:

TypeScript
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:

TypeScript
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));
Text
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.