Permissions & scopes
Authentication tells you who is calling; permissions decide what they can do.
Heximon's answer is scope-based: a signed token carries the caller's granted scopes inline, each route declares
the scopes it requires, and a guard compares the two before your handler runs. Scopes are plain strings — there
is no permission registry to install — so a common convention is domain:resource:action, like task:create
or admin:stats:read.
You enforce them one of two ways: automatically with GuardMiddleware for routes declared on a
contract, or manually with AuthContext.isAllowed(...) for inline routes. Both read the
same principal the auth middleware stored, and both produce the same 401 (no principal) / 403
(under-scoped) distinction.
Declare scopes on a contract route
Route.scopes(...) records the required scopes directly on the route descriptor, alongside its body and
response schemas. A route with no scopes() is open; a route with scopes requires a principal that grants
every one of them.
import { Contract, Route } from "@heximon/contract";
import { SchemaArray } from "@heximon/schema";
import { CreateTaskBody, TaskIdParams, TaskResponse } from "./task.dto";
class TaskListResponse extends SchemaArray(TaskResponse) {}
export class TaskApi extends Contract({
prefix: "/api/tasks",
routes: {
list: Route.get("/").scopes("task:read").responses({ 200: TaskListResponse }),
create: Route.post("/").scopes("task:create").body(CreateTaskBody).responses({ 201: TaskResponse }),
complete: Route.post("/:id/complete")
.scopes("task:complete")
.pathParams(TaskIdParams)
.responses({ 200: TaskResponse }),
},
}) {}
Keeping the scope on the contract means the requirement travels with the route everywhere the contract goes —
the server enforces it, OpenAPI documents it, and a client reads it from the same value. The dispatcher seeds
that descriptor into the request Context at match time, which is exactly where the guard looks for it next.
Enforce with GuardMiddleware
GuardMiddleware reads the matched route's scopes from the request Context and compares them against the
stored principal. Like JWTAuthMiddleware (see Authentication), it is a
bare provider — its constructor is only DI tokens (Context + AuthContext), so the compiler recovers it
from the package's shipped types and constructs it directly. There's no wrapper to write; list both shipped
classes in http.middlewares, auth first, guard second:
import { GuardMiddleware, JWTAuthMiddleware } from "@heximon/auth";
import { Module } from "@heximon/runtime";
import { TasksController } from "./tasks.controller";
export class AuthModule extends Module({
providers: [
// ...AuthContext / JWTFactory / IdentityKeyPair providers — see Authentication
],
http: {
controllers: [TasksController],
middlewares: [JWTAuthMiddleware, GuardMiddleware],
},
exports: [JWTAuthMiddleware, GuardMiddleware /* ...and the rest */],
}) {}
Then list the same chain on the contract-bound controller — auth first, guard second:
import { GuardMiddleware, JWTAuthMiddleware } from "@heximon/auth";
import { type Action, type Controller } from "@heximon/http";
import { TaskApi } from "./task.api";
export class TasksController
implements Controller<{ contract: TaskApi; middlewares: [JWTAuthMiddleware, GuardMiddleware] }>
{
// Runs only for a principal holding `task:read` — enforced before the handler.
public async list(action: Action<TaskApi, "list">) {
// ...
}
public async create(action: Action<TaskApi, "create">) {
// ... — requires `task:create`
}
public async complete(action: Action<TaskApi, "complete">) {
// ... — requires `task:complete`
}
}
For each request the guard resolves to one of four outcomes: open route → continue; scopes declared but no
principal → 401; principal lacking a scope → 403; all scopes granted → continue. Your handler never sees an
under-scoped caller.
401.GuardMiddleware reads the principal
that JWTAuthMiddleware stored earlier in the same chain — it does no token verification of its own. If the
guard runs first, the principal isn't there yet, so a route with declared scopes resolves to "scopes declared
but no principal" and returns 401 application/problem+json even for a perfectly valid token. The fix is the
list order: middlewares: [JWTAuthMiddleware, GuardMiddleware], auth before guard.Check scopes manually on inline routes
Inline routes — declared by the handler's Get<...> / Post<...> parameter type rather than a contract — carry
no scopes, so the guard has nothing to read. That's deliberate: inline routes trade the contract's shared
metadata for terseness, and authorization is one of the things you give up. Authenticate with the auth
middleware, then enforce the scope yourself through AuthContext.
import { AuthContext, JWTAuthMiddleware } from "@heximon/auth";
import type { Controller, Get } from "@heximon/http";
export class AdminController implements Controller<{
prefix: "/admin";
middlewares: [JWTAuthMiddleware];
}> {
private static readonly requiredScope: string = "admin:stats:read";
public constructor(private readonly authContext: AuthContext) {}
public async stats(action: Get<"/stats">): Promise<Response> {
if (this.authContext.principal() === null) {
return action.respond(401, { error: "Authentication is required." });
}
if (!this.authContext.isAllowed(AdminController.requiredScope)) {
return action.respond(403, { error: `Requires the '${AdminController.requiredScope}' scope.` });
}
return action.respond(200, { totalRequests: 42, activeSessions: 7 });
}
}
This produces exactly the 401 / 403 outcomes a guarded contract route would, because isAllowed reads the
same stored principal the guard would — the only difference is that you write the two checks by hand instead of
declaring them on a route.
Type your scope codes
By default a scope is any string. Narrow it to your app's exact permission codes by declaration-merging the
global RequestDefaults seam, so a typo in a scopes(...) call or an isAllowed(...) argument is a compile
error instead of a silently never-granted permission.
declare global {
interface RequestDefaults {
scopes: ("task:read" | "task:create" | "task:complete" | "admin:stats:read")[];
}
}
export {};
scopes must be typed as the array of your scope codes — ("a" | "b")[], not a bare union — because
RequestScope is derived as RequestScopes[number], which extracts the element type. Without this seam, scopes
fall back to string — fine for prototyping, but the typed array is worth the one file once your permission set
settles, because it turns "this guard never passes" debugging into a red squiggle.
See also
- Authentication — issue tokens carrying the right
permissionsclaim and wireAuthContext, the principal these scopes are checked against. - Contracts — where
Route.scopes(...)lives alongside thebodyandresponsesschemas on a shared route table. - Controllers — the two controller modes and how a
middlewares: [...]chain runs before a handler. - Example L07 — auth
— register, login, a request-scoped
AuthContext, and an/admin/statsroute that proves the manual401/403/200scope ladder.
Authentication
Verify callers with JWT, hash passwords through the PasswordHashingAlgorithm port, read the request-scoped AuthContext, and list JWTAuthMiddleware directly in your app.
Error Handling
Throw an error and get a clean response — the HeximonError family, ErrorFilter mapping, and the RFC 9457 problem+json envelope.