Extensions
Authorization
Authorization asks the second question: what may the caller do. @stone-js/authzanswers it with rules that run on both sides, so the server guard and the UI that hides a button are the same logic, and can never disagree.
#Install
npm i @stone-js/authz#Declare abilities once
The principle
When the rule that guards an action lives in one place and the rule that shows its button lives in another, they drift, and the UI eventually offers what the API refuses. Declare the rule once and evaluate it in both places.
In Stone.js
defineAbility declares what a user can do, combining role checks (RBAC) and attribute checks (ABAC). authorize(action, subject) enforces it as route middleware; the same ability object answers can(...) in the UI.
Under the hood it is CASL, so you get RBAC (action + subject type), ABAC (conditions on a subject instance) and field-level rules from one declaration, and the same ability runs on the server and in the browser.
import { defineAbility } from '@stone-js/authz'
export const abilityFor = (user) => defineAbility((can, cannot) => {
can('read', 'Task') // RBAC: anyone reads
can('update', 'Task', { ownerId: user.id }) // ABAC: only your own
can('update', 'Task', ['title', 'done']) // field-level: only these fields
if (user.role === 'admin') can('manage', 'Task') // 'manage' = every action
cannot('delete', 'Task', { locked: true }) // an explicit exception
})#RBAC, ABAC, fields, aliases
| Capability | Type | Description |
|---|---|---|
| can('delete', 'Task') | RBAC | Allow an action on a subject type. |
| can('update', 'Task', { ownerId }) | ABAC | Allow only when the subject instance matches the conditions. |
| can('update', 'Task', ['title']) | field-level | Restrict an action to specific fields. |
| can('manage', subject) | action alias | 'manage' matches every action; define your own aliases with createAliasResolver. |
| cannot('delete', 'Task', {...}) | exception | Carve out a denial that overrides a broader allow. |
#Enforce on the API
authorize(action, subject, field?) guards a route and throws a ForbiddenError (403) when the ability says no. For checks inside a handler, inject the Authorizer: it exposes can/cannot and an authorize that throws.
import { EventHandler, Delete } from '@stone-js/router'
import { authorize } from '@stone-js/authz'
@EventHandler('/tasks')
export class TaskController {
@Delete('/:id', { middleware: [authorize('delete', 'Task')] })
remove (event) { return this.tasks.remove(event.get('id')) }
}import { defineEventHandler, defineRoutes } from '@stone-js/router'
import { authorize } from '@stone-js/authz'
export const routes = defineRoutes([
[defineEventHandler(TaskController, 'remove'),
{ path: '/tasks/:id', method: 'DELETE', middleware: [authorize('delete', 'Task')] }]
])update (event: IncomingHttpEvent) {
const task = this.tasks.find(event.get('id'))
// Check against a concrete instance (ABAC) with subject() typing.
this.authorizer.authorize(event.get('user'), 'update', subject('Task', task))
return this.tasks.update(task, event.get('body'))
}#Reuse in the UI
Because the ability is a value, the frontend asks the same question before rendering a control, so the UI never offers an action the API would reject, down to individual fields.
{ability.can('delete', task) && <button onClick={remove}>Delete</button>}
<input disabled={ability.cannot('update', task, 'title')} />One rule set. It guards the route, the field, and the button. They can never disagree.