Model builders
defineModel, defineType, relation, permission, ttu, wildcard, and defineCondition.
The builders, in scope for every example on this page:
import type { SetExpression } from '@tsbouncer/tsbouncer';
import { createAuthz, defineCondition, defineModel, defineType, permission, relation, ttu, wildcard } from '@tsbouncer/tsbouncer';
import { memoryStore } from '@tsbouncer/in-memory';
const store = memoryStore();defineModel
defineModel<C extends ModelConfig>(config: C): ModelValidates immediately and throws ModelDefinitionError on: no types, a name that
is not [a-z_][a-z0-9_]*, a condition registered under a different name, a
relation and permission sharing a name, a cross-type reference to a relation that
does not exist, or a cycle the validator can see.
const model = defineModel({
types: { /* … */ },
conditions: { /* … */ },
});The generic parameter is load-bearing: it is what makes PermissionOf<typeof model>
yield literal permission names rather than string. Do not annotate the result
with a wider type.
defineType
defineType<const C extends TypeConfig = TypeConfig>(config?: C): CIdentity at runtime. The generic is the point — it preserves the literal config so the type-level derivations can see the relation and permission names.
user: defineType({}), // no relations, no permissions
team: defineType({ relations: { member: relation(['user']) } }),
doc: defineType({
relations: { owner: relation(['user']) },
permissions: { read: permission.or('owner') },
}),relation
relation(type: string | readonly string[], options?: { through?: string }): SetExpressionDeclares an edge tuples may be written against.
const one: SetExpression = relation('user');
const several: SetExpression = relation(['user', 'team']);
const userset: SetExpression = relation('team', { through: 'member' });
void [one, several, userset];wildcard
wildcard(type: string): SetExpressionDeclares that a relation accepts a type:* subject. It is a write-side
declaration: the relation grants nobody until a tuple exists.
ttu
ttu(through: string, target: string): SetExpression“Follow through, then take target over there.” This is how a permission
inherits — a userset edge names a relation, and target is usually a permission.
folder: defineType({
relations: { viewer: relation('user'), parent: relation('folder') },
permissions: { read: permission.or('viewer', ttu('parent', 'read')) },
});permission
permission.or(...children): SetExpression
permission.allOf(...children): SetExpressionChild is a relation name or another SetExpression, so expressions nest.
permissions: {
read: permission.or('owner', 'editor', ttu('parent', 'read')),
write: permission.allOf('owner', 'editor'),
admin: permission.allOf(permission.or('owner', 'admin'), 'active'),
}SetExpression also carries fluent or, and, and except:
viewer: relation('user')
.or(relation('team', { through: 'member' }))
.or(relation('role', { through: 'holder' }))
.or(wildcard('user'));defineCondition
defineCondition(
name: string,
predicate: (context: ConditionContext) => boolean,
options?: { params?: Record<string, 'string' | 'number' | 'boolean'> },
): ConditionDefThe predicate is code in the model; only the name and the bound parameters live on the tuple.
sameRegion: defineCondition(
'sameRegion',
(ctx) => {
const { region, callerRegion } = ctx;
if (typeof region !== 'string' || typeof callerRegion !== 'string') return false;
return region === callerRegion;
},
{ params: { region: 'string' as const } },
);Next
- The model — the semantics these builders express.
- API: errors