Tsbouncer | Authorization Kernel for TypeScript

typescript node.js pnpm turborepo vitest biome redis drizzle

Cover Image

tsbouncer | Authorization Kernel for TypeScript


Note

tsbouncer is at 1.0.0-preview.1, published on npm under the preview tag (2026-09-30). The API is not settled before 1.0 — preview versions chain as 1.0.0-preview.N. Docs live at mahabubone.github.io/tsbouncer.

Project Overview

Introduction

tsbouncer (“the bouncer for TypeScript/JS ESM-native apps”) is an open-source authorization library. Authorization logic usually ends up scattered — a check in a route handler, a different one in a worker, a third in a cron job — and they drift until nobody can answer “who has access to this document?” without reading all of them.

tsbouncer gives you one model, one set of tuples, and one evaluator, then stays out of the way:

const decision = await authz.check({
  subject: "user:alice",
  permission: "document.read",
  resource: "document:123",
});

You do whatever you want with the result. Throw, return 403, branch, log it.

What it is not

No HTTP layer or framework middleware, no authentication or sessions, no JWT/OAuth, no CLI or hosted service. Your application owns the request lifecycle and the business logic; tsbouncer owns authorization semantics and access to authorization data.


✨ Key Features

Model & Data Separation

  • Model is code - types, relations, permissions, and conditions, validated at runtime by defineModel
  • Data is tuples - opaque subject#relation@resource grants, no foreign keys into your schema
  • Exact permission unions - document.riad is a build failure, not a test failure

Two Ports, Seven Adapters

  • TupleStore - the durable record of grants; filtered reads are the only mandatory primitive, everything else is derived by the engine
  • Cache - the fast, losable layer in front of repeated questions, behind withCache
  • Adapters are plugins - in-memory, json-file, redis, kysely, drizzle, prisma, and defaults (which picks one for you)

Explainable Decisions

  • explain() - every allow/deny returns a JSON-serializable tree citing the tuples that produced it, with truncated on the result
  • formatExplain() - a text view of that same structure, so the two cannot disagree

Graph Queries

  • expand, listResources, listSubjects - the three reads that answer a question about a whole graph rather than one edge
  • Truncation is explicit - one node budget per request, and a partial answer is never mistaken for a whole one

Conditions (ABAC)

  • Named predicates - only the name and its bound parameters live on the tuple, so stores never evaluate anything
  • Writer-side parameters are authoritative - a request cannot rewrite the constraint the grant was written with
  • Missing or mistyped keys deny - a silent undefined becomes a reason, not a mystery

Fails Closed

  • A throwing condition, an undeclared condition, an unresolvable reference, or an exhausted depth/node/deadline budget all resolve to not allowed — never thrown through to the caller. A store failure is a failure, not a denial — isAuthorizationError tells them apart

🧱 Architecture

Monorepo

tsbouncer/
├── packages/
│   ├── tsbouncer/        # @tsbouncer/tsbouncer — kernel + the two ports, zero deps
│   ├── stores/           # @tsbouncer/{in-memory,json-file,redis,kysely,drizzle,prisma,defaults}
│   └── testkit/          # @tsbouncer/testkit — store + cache conformance and golden suites
├── examples/
│   ├── hono-rbac/        # Hono routes + RBAC graph in a JSON file — 19 live requests
│   └── express-drizzle/  # Express + Drizzle + SQLite, RBAC/ReBAC/ABAC — 50 live requests
├── docs/                 # docs site, static output for GitHub Pages
└── scripts/

Storage Ports

One primitive is mandatory: a filtered tuple read. Reverse walks, expand, and listResources are derived by the engine. Stores stay dumb; they move tuples, they don’t decide anything.

PackageImplementsTakesTested against
@tsbouncer/in-memorystore + cache (memoryStore, memoryCache)nothing — process-localin-process
@tsbouncer/json-filestore (jsonStore)a file pathon disk, atomic
@tsbouncer/redisstore + cache (redisStore, redisCache)your Redis clientlive Redis 7.4 via env gate
@tsbouncer/kyselystore (kyselyStore)your Kysely instanceSQLite in CI, PostgreSQL locally
@tsbouncer/drizzlestoreyour db and table objectSQLite (sync), libsql (async)
@tsbouncer/prismastoreyour PrismaClientSQLite via Prisma 7
@tsbouncer/defaultsneither — it choosesnothing, or a file path—

Every port implementation must pass the same conformance suites in @tsbouncer/testkit (storeConformance and cacheConformance) — an adapter that does not pass is not finished.


🚀 Quick Start

import { createAuthz, defineModel, defineType, permission, relation } 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" })),
      },
      permissions: {
        read: permission.or("owner", "editor"),
      },
    }),
  },
});

const authz = createAuthz({ model, store: memoryStore() });

await authz.write([
  { subject: "user:alice", relation: "owner", resource: "document:1" },
  { subject: "team:eng#member", relation: "editor", resource: "document:2" },
  { subject: "user:alice", relation: "member", resource: "team:eng" },
]);

await authz.can("user:alice", "document.read", "document:1"); // true  — owner
await authz.can("user:alice", "document.read", "document:2"); // true  — team member
await authz.can("user:bob", "document.read", "document:2"); // false — not a member

Install it:

npm i @tsbouncer/tsbouncer @tsbouncer/in-memory

The client is small and complete: can, check, assert, explain, grant / write / revoke / delete, listResources, listSubjects, expand, plus withStore (bind to a transaction handle) and withCache (memoize decisions with resource-scoped invalidation and a required model-versioned namespace).


📊 Contract

Every claim is a clause checked against a real store on every CI run, with the report uploaded as an artifact:

contract summary
  ✓ engine guarantees  21/21
  ✓ store guarantees   4/4

A few of the clauses: a direct grant allows only its subject · naming a group grants the group object, not its members · user:* reaches every subject of that type, including ones that do not exist yet · an exclusion beats a satisfied base and does not leak to other permissions · permission flows downward, never upward · a tuple’s bound parameters are authoritative · a cycle terminates and denies · list queries never contradict check · a budget that runs out denies, and says so via truncated.


🛠️ Tooling

  • TypeScript 5.9 - strict mode, ESM-native, no CommonJS build, Node >=22
  • pnpm + Turborepo - workspace for the kernel, stores, testkit, docs, and examples
  • Vitest - unit, contract, conformance, and coverage gates
  • Biome - lint + format, enforced by husky, lint-staged, and commitlint
  • GitHub Actions - pnpm check runs lint, versions, typecheck, build, pack:check, coverage, and both example apps

📍 Status

AreaStatus
Kernel + check engineBuilt and tested
Store/cache adapters (7)Published, passing conformance
Graph queriesexpand, listResources, listSubjects done
ExamplesTwo runnable apps, executed in CI
Docs siteLive on GitHub Pages
npm1.0.0-preview.1 published (preview tag)
1.0.0Next gate — API settles then

Next Steps

  1. Soak the preview - 1.0.0-preview.N until the API settles, then 1.0.0
  2. Community adapters - TypeORM, MikroORM, Sequelize, Mongoose plug into the same two ports
  3. Comparison catalog - Zanzibar / OpenFGA rows, including where tsbouncer is the worse answer


📄 License

Apache-2.0 — free to use, modify, and distribute.


🙏 Acknowledgments

  • Zanzibar and OpenFGA — the relationship-based access control models this sits next to
Found this project useful? Support my work ☕