FEATURES / RBAC
A permission matrix
TypeScript can read.
Roles defined as a single typed object. Every guarded endpoint checks against it. ESLint flags missing handlers when you add a new role or action.
Roles + actions, in one file.
Typed permission matrix
Roles × actions, stored as a Record<Role, Set<Action>>. Discriminated unions everywhere.
Guard middleware
requireAction(actor, "billing:cancel") throws ForbiddenError. The HTTP layer translates to 403.
Resource ownership
Combine role checks with ownership predicates: "members can edit their own profile, admins anyone's".
UI mirroring
Server returns the actor's permission set; the dashboard hides actions the user can't take. No phantom buttons.
Adding a role is a 5-line PR.
Add a role to the union, fill in the permission cells. ESLint flags every consumer that needs to handle it. The build won't pass until the matrix is complete.
1 export type Role = "owner" | "admin" | "member" | "viewer";
2
3 export const PERMISSIONS: Record<Role, Set<Action>> = {
4 owner: new Set(["*"]),
5 admin: new Set(["billing:read", "users:invite", ...]),
6 member: new Set(["projects:read", "projects:write"]),
7 viewer: new Set(["projects:read"]),
8 };
Authorization without YAML.
A typed matrix the compiler can verify, not a config file.