
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@resourcegrants, no foreign keys into your schema - Exact permission unions -
document.riadis 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 engineCache- the fast, losable layer in front of repeated questions, behindwithCache- Adapters are plugins -
in-memory,json-file,redis,kysely,drizzle,prisma, anddefaults(which picks one for you)
Explainable Decisions
explain()- every allow/deny returns a JSON-serializable tree citing the tuples that produced it, withtruncatedon the resultformatExplain()- 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
undefinedbecomes 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 —
isAuthorizationErrortells 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.
| Package | Implements | Takes | Tested against |
|---|---|---|---|
@tsbouncer/in-memory | store + cache (memoryStore, memoryCache) | nothing — process-local | in-process |
@tsbouncer/json-file | store (jsonStore) | a file path | on disk, atomic |
@tsbouncer/redis | store + cache (redisStore, redisCache) | your Redis client | live Redis 7.4 via env gate |
@tsbouncer/kysely | store (kyselyStore) | your Kysely instance | SQLite in CI, PostgreSQL locally |
@tsbouncer/drizzle | store | your db and table object | SQLite (sync), libsql (async) |
@tsbouncer/prisma | store | your PrismaClient | SQLite via Prisma 7 |
@tsbouncer/defaults | neither — it chooses | nothing, 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 checkruns lint, versions, typecheck, build, pack:check, coverage, and both example apps
📍 Status
| Area | Status |
|---|---|
| Kernel + check engine | Built and tested |
| Store/cache adapters (7) | Published, passing conformance |
| Graph queries | expand, listResources, listSubjects done |
| Examples | Two runnable apps, executed in CI |
| Docs site | Live on GitHub Pages |
| npm | 1.0.0-preview.1 published (preview tag) |
| 1.0.0 | Next gate — API settles then |
Next Steps
- Soak the preview -
1.0.0-preview.Nuntil the API settles, then 1.0.0 - Community adapters - TypeORM, MikroORM, Sequelize, Mongoose plug into the same two ports
- Comparison catalog - Zanzibar / OpenFGA rows, including where tsbouncer is the worse answer
🔗 Links
- Repository: github.com/mahabubone/tsbouncer
- Docs: mahabubone.github.io/tsbouncer
- npm: npmjs.com/org/tsbouncer
- Guarantees: docs/reference/guarantees
- Contributing: CONTRIBUTING.md — issues labeled
good first issueandhacktoberfest
📄 License
Apache-2.0 — free to use, modify, and distribute.