Stone.jsDocs
Paradigm

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

terminalbash
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.

app/abilities.tsts
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

CapabilityTypeDescription
can('delete', 'Task')RBACAllow an action on a subject type.
can('update', 'Task', { ownerId })ABACAllow only when the subject instance matches the conditions.
can('update', 'Task', ['title'])field-levelRestrict an action to specific fields.
can('manage', subject)action alias'manage' matches every action; define your own aliases with createAliasResolver.
cannot('delete', 'Task', {...})exceptionCarve 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.

app/Tasks.tsdeclarativeimperative
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')] }]
])
app/Tasks.tsts
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.

app/pages/TaskRow.tsxtsx
{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.

Stone.js

Your app exists in every runtime. Until you run it.

An open-source project by Stone Foundation
Created by Mr. Stone (Evens Pierre)