Skip to content
tsbouncerpreview
reference

Client

Every method on the object createAuthz returns, and which one to reach for.

createAuthz({ model, store }) returns the only object you need to hold. Every example on this page runs against this setup:

TypeScript
import { createAuthz, defineModel, defineType, isAuthorizationError, permission, relation } from '@tsbouncer/tsbouncer';
import { memoryStore } from '@tsbouncer/in-memory';
import type { EvaluationLimits, Model, TupleStore } from '@tsbouncer/tsbouncer';

const model = defineModel({
  types: {
    user: defineType({}),
    document: defineType({
      relations: { owner: relation(['user']) },
      permissions: { read: permission.or('owner') },
    }),
  },
});

const store: TupleStore = memoryStore();
const authz = createAuthz({ model, store });
TypeScript
interface CreateAuthzOptions {
  model: Model;
  store: TupleStore;
  limits?: Partial<EvaluationLimits>;
  validate?: boolean; // default true
}

createAuthz

TypeScript
createAuthz({ model, store, limits, validate }): Authz

Validates the store’s shape immediately, so a malformed store fails at construction rather than on the first read. limits caps depth, node count, and wall-clock time per evaluation; validate turns write-time model validation off, and the only good reason to is replaying data written under a previous model.

Deciding

can

TypeScript
can(subject, permission, resource, options?): Promise<boolean>

The boolean form. options accepts the same context as everything else, so a conditional permission is expressible here too.

TypeScript
await authz.can('user:dave', 'document.read', 'document:1', {
  context: { callerRegion: 'eu' },
});

check

TypeScript
check(request, options?): Promise<Decision>

The same decision with the request echoed back, which is what you want in a log line or an audit record.

TypeScript
interface Decision {
  allowed: boolean;
  subject: string;
  permission: string;
  resource: string;
  truncated: boolean;
}

truncated says the budget ran out mid-evaluation, so allowed: false is a lower bound rather than a denial from the data. explain() carries the same flag alongside its tree.

assert

TypeScript
assert(request, options?): Promise<void>

Throws AccessDeniedError when denied. Use it where a denial means the caller is in an invalid state and the route must not continue.

TypeScript
async function requireWrite(subject: string, resource: string): Promise<Response | undefined> {
  try {
    await authz.assert({ subject, permission: 'document.write', resource });
  } catch (error) {
    if (isAuthorizationError(error) && error.code === 'access_denied') {
      return new Response('forbidden', { status: 403 });
    }
    throw error;
  }
}

explain

TypeScript
explain(request, options?): Promise<ExplainResult>

The decision and the reasoning. See The decision for a worked transcript.

Whole-graph reads

listResources

TypeScript
listResources({ subject, permission, context? }): Promise<ResourceList>

Everything the subject holds permission on, including access inherited from a folder or a group. On what that costs at scale, see listResources is O(store size).

TypeScript
interface ResourceList {
  resources: readonly string[];
  truncated: boolean;
}

listSubjects

TypeScript
listSubjects({ permission, resource, context? }): Promise<SubjectSet>

Who holds a permission on a resource. Deliberately not always a concrete list: a grant that covers a class of subjects is reported symbolically, because inventing a member list would be a guess.

TypeScript
const { allOfTypes, members, excluded, truncated: subjectListTruncated } =
  await authz.listSubjects({
    permission: 'document.public',
    resource: 'document:1',
  });
// allOfTypes: ['user']            "any user", not a list of users
// members:     ['user:alice', 'team:eng#member']
// excluded:    ['user:mallory']    the `except` side, not flattened away

excluded is populated when the base is symbolic and a named subject is carved out of it — “every user except mallory”. A concrete set that subtracts to empty is simply empty.

expand

TypeScript
expand({ subject }, options?): Promise<ExpandResult>

Every concrete subject a userset contains, transitively. A wildcard expands to itself rather than to an invented list. Conditional memberships are gated on options context — the tuple’s bindings win and the request fills the gaps, the same merge check applies — so the expansion never lists a member the decisions would deny.

TypeScript
const { subjects, truncated } = await authz.expand({ subject: 'team:eng#member' });

Writing

TypeScript
grant(input: GrantInput): Promise<void>
write(tuples: readonly Tuple[], mode?: 'insert' | 'upsert'): Promise<void>
revoke(input: GrantInput): Promise<void>
delete(input: DeleteInput): Promise<void>

Scoping to a transaction

TypeScript
withStore(store: TupleStore): Authz

Returns a client bound to another store — typically a transaction handle. The per-request memo is keyed by store identity, so results are never computed against the outer store and returned as if they came from the transaction.

TypeScript
declare function inTransaction<T>(fn: (store: TupleStore) => Promise<T>): Promise<T>;

await inTransaction(async (store) => {
  const scoped = authz.withStore(store);
  await scoped.grant({ subject: 'user:alice', relation: 'owner', resource: 'document:1' });
  // decisions made here see the write above, and nothing outside the transaction
});

Memoizing decisions

TypeScript
withCache(authz: Authz, cache: Cache, options: CachedAuthzOptions): Authz

Wraps a client so can and check consult a Cache first. Hits serve validated values only; misses, corrupt entries, and backend failures all fall through to evaluation. Every mutation invalidates by resource — a scope without one concrete resource clears the whole namespace instead of guessing — and a failed invalidation disables caching rather than risking a stale allow. explain, expand, and the list queries stay uncached, and withStore drops the layer, because transactional reads must never hit shared cache.

The namespace is required and should be versioned with the model. Two applications sharing one cache must never read each other’s decisions:

TypeScript
import { withCache } from '@tsbouncer/tsbouncer';
import { memoryCache } from '@tsbouncer/in-memory';

const cached = withCache(authz, memoryCache(), { namespace: 'docs-api-v1' });

await authz.grant({ subject: 'user:alice', relation: 'owner', resource: 'document:1' });
await cached.can('user:alice', 'document.read', 'document:1'); // true, evaluated once
await cached.can('user:alice', 'document.read', 'document:1'); // true, from the memo

Introspection

TypeScript
types(): string[]
relations(type: string): string[]
permissions(type: string): string[]

For building admin UIs. A model built at runtime is not knowable from the types alone, and these are how you ask.

Next