Authorization
Who may do what — role permissions with can(), and resource-level rules (owner, assignee) in a slice's policy.ts. Use when a rule depends on the ROW and not just the role, or when tempted to compare ctx.userId in a component.
Generated from
.claude/skills/authorization/SKILL.md, which ships in every project created from this template. Your agent loads it on demand; this page is the same text. Edit the skill, not this page.
Two layers, and they answer different questions.
Role permissions answer "may this ROLE do this kind of thing":
can(ctx, 'tickets.update'); // in a page, to decide what to render
requirePermission(ctx, 'tickets.update'); // in a query, to enforce itDeclared in src/config/permissions.ts, granted per role, never compared as
a role string (no-role-string-compare is a lint error).
Resource rules answer "may this PERSON do this to THIS ROW" — assignee, owner, author. Permissions cannot express that: the grant is the same for every member, and the answer depends on the record.
The pattern: policy.ts in the slice
// src/features/tickets/policy.ts
import 'server-only';
import { can, type TenantContext } from '@/lib/auth/context';
export function canResolve(
ctx: TenantContext,
ticket: Pick<Ticket, 'assigneeId'>,
): boolean {
return can(ctx, 'tickets.resolve') || ticket.assigneeId === ctx.userId;
}
/** What this caller may do to this row — the page renders what it returns. */
export function allowedTransitionsFor(ctx: TenantContext, ticket: Ticket) {
return TRANSITIONS[ticket.status].filter(
(to) => !isRestricted(to) || canResolve(ctx, ticket),
);
}Three properties make this the sanctioned shape:
- One module per slice,
server-only. The rule is written once and read by both the page (to decide what to render) and the action (to enforce it), so the UI can only ever narrow what the server already refuses. - It builds on
can(), it does not replace it. The role grant is still declared inpermissions.ts; the policy adds the row-dependent exception. - It returns DATA, not JSX decisions.
allowedTransitionsForgives the page a list; the page renders buttons from it. That is what keeps a component from re-deriving authorization.
Never compare ctx.userId outside policy.ts
ticket.assigneeId === ctx.userId scattered through components is the same
failure as comparing role strings: correct today, silently wrong the day the
rule gains a case, and impossible to grep for. Put it in the policy module and
call the policy.
The action still enforces it
A policy consulted only by the page is decoration — hiding a button is convenience, not security. The mutation re-checks:
if (!canResolve(ctx, ticket))
throw forbidden('Only the assignee or an admin can resolve this.');FORBIDDEN reaching a user who typed a URL is a correct outcome, not a bug.
Server actions
How to write a mutation in this project — the typed action client, its staged builder, ActionResult, and the AppError taxonomy. Use when adding or changing anything in an actions.ts file, or when the compiler says .mutation() does not exist.
Forms
How to build a form in this project — useActionForm, the Field primitive, where the schema comes from, and how server-side field errors get back to the right input. Use when adding or changing any form, or when RHF's error types will not line up.