Roles and capabilities
Code-defined roles, capability guards, and the collections API's default protection.
Authorization in ohne is capability-based. A capability is a permission string like collection.Posts.create. A role is a named bundle of capabilities, defined in code. A user holds any number of roles, and the capabilities of every held role union - what any role grants, the user can do.
Roles live in your project, not in the database. They are policy, and policy is code: reviewed, versioned, and typed like everything else. The database stores only the assignment - which role names a user holds.
// roles/editor.ts
import { defineRole } from 'ohnejs';
export default defineRole({
capabilities: ['collection.Posts.*', 'collection.Tags.read'],
});A user with roles: ['editor'] can now do anything on Posts and read Tags, both in the collections API and behind any guard you write.
Capabilities
A capability is a dot-separated string. Every collection contributes one per operation, plus a wildcard:
collection.Posts.read
collection.Posts.create
collection.Posts.update
collection.Posts.delete
collection.Posts.*collection.* covers every collection capability, and * alone covers everything - that is what makes an admin. Wildcards live on the granting side only: a role holds collection.Posts.*, but a check always asks for one concrete capability.
Codegen derives these names from your collections, so they autocomplete wherever a capability is expected. Any other dot-separated string is legal too - see custom capabilities.
Defining roles
Each .ts file under roles/ is one role, named by its kebab-cased path: roles/editor.ts is editor, roles/shop/manager.ts is shop-manager. The file default-exports a defineRole result, and codegen types every name into RoleName, so assignments autocomplete and a typo is a compile error.
The ohne layer ships the admin role, holding ['*']. There is no separate superuser flag - the wildcard is the bypass. Your app can override it by shipping its own roles/admin.ts, or drop it with disable: { roles: ['admin'] }.
Assigning roles
The Users collection carries a roles field: the list of role names the user holds. It defaults to [], deduplicates on write, and rejects a name no role file defines. A role you later delete from code simply grants nothing - a stale assignment degrades, it never breaks.
Adding a roles field to your own collection that already holds rows is the standard new-required-field story: add it nullable: true, backfill, then drop the flag with a switch migration.
The first admin needs no code. While Users is empty, the dashboard opens its install page, which creates the account with the admin role and signs it in. POST /auth/install with { email, password } does the same over HTTP and answers 403 once any user exists.
Because Users is itself exposed over the collections API, that first admin can then manage every account over HTTP - creating users, assigning roles - guarded by the collection.Users.* capabilities.
The collections API guard
An operation a collection exposes is guarded by default: the request needs a signed-in user whose capabilities cover collection.<Name>.<operation>. No user is a 401, a user without the capability a 403. An operation marked 'public' skips the guard.
The guard answers who may run an operation. Which records they reach - only their own posts, only published ones - is the operation's access option.
Guarding your own routes
For your own endpoints, requireCapability is the one-line guard. It resolves the signed-in user, checks the capability against their union, and throws 401 or 403 exactly as the collections API does:
// api/publish.post.ts
import { defineHandler } from 'ohnejs';
import { requireCapability } from 'ohnejs/auth';
export default defineHandler(async () => {
await requireCapability('collection.Posts.update');
return publishDrafts();
});To branch instead of reject, userCan answers the same question as a boolean, and userCapabilities returns the resolved union. Both work from the user's roles and the role files - no query runs:
import { requireUser, userCan, userCapabilities } from 'ohnejs/auth';
const user = await requireUser();
userCan(user, 'collection.Posts.update'); // -> true or false
userCapabilities(user); // -> ['collection.Posts.*', 'collection.Tags.read']Custom capabilities
A capability does not have to name a collection. Any dot-separated string works, so a feature can carve its own namespace:
// roles/accountant.ts
import { defineRole } from 'ohnejs';
export default defineRole({
capabilities: ['billing.read', 'billing.export'],
});await requireCapability('billing.export');A custom name needs no declaration to work, but nothing types it: codegen derives the known names from your collections alone, so billing.export does not autocomplete. Declare it yourself with the same declare module the other extension points take, in any file your tsconfig.json includes:
// capabilities.ts
declare module 'ohnejs' {
interface KnownCapabilities {
'billing.read': true;
'billing.export': true;
}
}Both names now complete in defineRole, requireCapability, and userCan, beside the generated ones. The union stays open, so a name you did not declare still typechecks; the declaration buys completion, not rejection. A layer declares its names the same way.
Prefix a layer's capabilities with its name and they cannot collide with an app's own.
Roles across layers
Roles stack like everything a layer ships: each layer's roles/ directory is scanned, a closer layer's role replaces a further one's under the same name, and disable: { roles: [...] } drops names entirely. The directory is configurable per layer as dirs.roles.