@ooneex/role package provides config-agnostic role-based access control (RBAC). You declare your roles and how they inherit one another in a single configuration, and the Role class answers one question at a time: does a user’s role grant a required role through the hierarchy? Roles attach to the user; routes declare which roles may reach them; the runtime checks the two against the inheritance graph before a controller runs.
Why roles
- Config-agnostic. You define your own role names and hierarchy in YAML (or in code) — the package ships sensible defaults but imposes no fixed set.
- Hierarchy, not duplication. A role
inheritsits ancestors, so grantingROLE_ADMINautomatically grants everythingROLE_USERcan do. No copy-pasting grants across roles. - Type-safe. Role identifiers are
Uppercase<string>, andgenerateRolesTypesturns your config into a literal union so typos fail at compile time. - Zero dependencies. Pure graph traversal — works in the browser and on Bun, with nothing to install at runtime.
- Framework integration. The
rolesfield on a route plugs straight into Ooneex routing and the auth pipeline.
How it works
Authorization resolves against the inheritance graph. A user holds a role; a route requires one or more roles; access is granted when the user’s role is a required role or inherits it (directly or transitively). Siblings on different branches never satisfy each other.| Stage | What happens |
|---|---|
| Config | Roles and their inherits edges are declared in roles.yml (or a RolesConfigType object). |
| Validation | validateConfig checks required keys exist and every inherits target is defined, throwing RoleException otherwise. |
| Resolution | Role.getInheritedRoles(role, config) walks the graph, returning ancestors first and the role itself last. |
| Enforcement | Role.hasRole(userRole, requiredRole, config) returns true when the user’s role is or inherits the required role. |
The roles.yml configuration
A config has two sections. roles maps short keys to their full ROLE_* identifiers; hierarchy describes each role’s inherits edges and a human description. Inheritance flows upward — a child lists the parents it absorbs.
roles.yml
rolesConfig:
Role naming convention
Role identifiers are uppercase and prefixed withROLE_ — they are typed as Uppercase<string>. The same identifiers appear everywhere a role is referenced: in the hierarchy keys, in a user’s assigned roles, and in the roles array on a route. Routing declares the field as roles: Uppercase<string>[], so a protected route reads roles: ["ROLE_ADMIN", "ROLE_SUPER_ADMIN"]. Keeping the convention consistent is what lets the generated types catch a misspelled role.
The Role class
Role is the access-control engine. Construct it with no arguments and pass your config to each call — it holds no state.
| Method | Signature | Returns |
|---|---|---|
hasRole | hasRole(userRole: Uppercase<string>, requiredRole: Uppercase<string>, config: RolesConfigType) | boolean — true when userRole is or inherits requiredRole; false for unknown roles or siblings. |
getInheritedRoles | getInheritedRoles(role: Uppercase<string>, config: RolesConfigType) | Uppercase<string>[] — every role inherited, ancestors first, ending with the role itself; [] if the role is unknown. |
IRole interface, so you can swap in your own implementation where one is expected.
Validating the config
validateConfig(config) enforces the contract before the config is trusted. It checks that the required role keys exist (GUEST, TRIAL_USER, USER, PREMIUM_USER, ADMIN, SUPER_ADMIN, SYSTEM), that every role maps to a hierarchy entry, that each entry has a non-empty description, and that every inherits target is itself defined. Any failure throws a RoleException. Run it once at startup or in a test so a broken config never ships.
Generating role types
generateRolesTypes(config) returns a string of TypeScript that turns your config into literal types — a RoleType union of role keys, a RoleHierarchyRoleType union of hierarchy roles, and a TypedRolesConfigType that ties them together. Writing this output to a .ts file gives you compile-time safety: referencing a role that does not exist becomes a type error instead of a silent runtime miss.
roles.yml so the types stay in sync with the config.
Enforcing access on a route
A route declares the roles permitted to reach it through theroles field. The runtime resolves the request’s user, reads the user’s role, and grants access only when it satisfies one of the listed roles through the hierarchy — so listing ROLE_MANAGER also admits ROLE_ADMIN and ROLE_SUPER_ADMIN. Roles answer who a user is; permissions answer what they may do — the two compose.
roles sits alongside permission, env, ip, and host in the access-control checks.
RoleException
RoleException extends the framework Exception and is thrown for role failures — a malformed config or a denied check. It carries the offending role as its key and resolves to HTTP 403 Forbidden, so a denial surfaces as the correct status without extra mapping.
Best practices
- Prefer hierarchies over flat grants. Model
inheritsedges instead of duplicating the same role across many routes; granting an ancestor grants its descendants’ reach automatically. - Keep role names stable. Routes, users, and stored data all reference the
ROLE_*identifiers — renaming one is a breaking change across the system. - Validate at startup. Call
validateConfigearly (or in a test) so an undefinedinheritstarget or missing description fails fast rather than at request time. - Regenerate types after config changes. Re-run
generateRolesTypeswhenever you editroles.ymlso a typo’d role is a compile error, not a silent denial. - List the lowest role that qualifies. Because higher roles inherit lower ones, name the minimum required role on a route and let the hierarchy admit everyone above it.
- Compose with permissions. Use roles for broad identity tiers and permissions for fine-grained actions; combine both on sensitive routes.