Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

Authorization

One policy that says what an owner, an admin and a member of an organization may do, and one way to ask it.

@zap-ts/authorization answers one question: may this member do this? It is the smallest package in the workspace. It holds three roles, four actions, and a policy. A policy is a set of rules that maps a role to the actions it may do.

The package never touches the database or the session. You pass it a role, and it answers.

It is built on @zap-studio/permit. You declare the actions with Zod. So an action that does not exist is a type error, not a silent false.

Roles

import { isOrganizationRole } from "@zap-ts/authorization";

if (isOrganizationRole(role)) {
  // `role` is now "owner", "admin" or "member".
}

OrganizationRole is "owner" | "admin" | "member".

A role reaches your code as a plain string. It comes from a member row, or from a Better Auth call. So you call isOrganizationRole first. It tells TypeScript which of the three values you have.

A role the policy does not know counts as no role at all. That is why getActiveMembership in Authentication returns null instead of guessing.

The policy

import { organizationPolicy } from "@zap-ts/authorization";

const canInvite = await organizationPolicy.can(
  { actor: { role: "admin" } },
  "organization:invite-member",
  { id: "org_123" },
);

The organization resource has four actions:

Action Who
view everyone
update-settings owners and admins
manage-billing owners and admins
invite-member owners and admins

can is async and returns true or false. You pass it { actor: { role } } and { id }. It reads nothing else. So the call is cheap. You can put it inside a render or a loop.

The policy is exported from . and from @zap-ts/authorization/organization. Import it from ..

Who calls it

Three callers use the policy. All of them live outside this package. All of them narrow the role first.

Billing is the main one. canManageBilling reads the member row for a user and an organization. It narrows the role. Then it asks the policy for organization:manage-billing.

import { canManageBilling } from "@zap-ts/billing/access";

Every payment provider passes that same function to its authorizeReference hook. So one place decides who may start a checkout or open the customer portal. No provider decides it on its own.

readOnboardingState and completeOnboarding in Authentication ask for organization:update-settings. They ask before they offer to rename the organization. So a member who joined through an invitation never sees that field.

Adding a rule

Open packages/authorization/src/organization.ts. Then:

  1. Add the name of your action to the actions list.
  2. Add a rule for it under rules.organization.

Use allow() to let everyone do it. Use when(predicate) to run a check on the role:

const canManageOrganization = (ctx: PermissionsContext) =>
  ctx.actor.role === "owner" || ctx.actor.role === "admin";

TypeScript checks the two lists against each other. An action with no rule does not build. A rule for an action that does not exist does not build either.

  • Authentication — where a role is read from a session
  • BillingcanManageBilling and the authorizeReference hook
  • Database — the member table that stores the role

Last updated on September 22, 2026

Was this page helpful?