Skip to main content
can is the only gate shape in Alfiz. Every route handler, server action, and API endpoint that enforces access control calls can (or its throwing form require) on a concrete permission key at a concrete scope. The key is verified against your catalog before evaluation, so a key the catalog does not declare raises UnknownPermissionError, which is a programming error, not a denial, and must never be mapped to a 403.

can(principal, key, scope?)

PrincipalRef
required
Identifies who is being checked. Either { userId: string } for a human user or { serviceId: string } for a machine principal. The client resolves the full subject closure (groups, organizations, implicit directs: and orgof: groups, and everyone) from the provider and caches it for the duration of subjectCacheTtlMs.
K | readonly K[]
required
A permission key (or array of keys) declared in your catalog. If you pass an array, the check returns true when the principal holds any of the listed keys at the given scope. The key is a typed string inferred from your catalog, so the TypeScript compiler rejects keys that are not in the catalog at literal call sites, and the runtime rejects them everywhere else.
LooseScopeId<S>
Optional. A scope instance id of the form <scopeType>:<instanceId>, for example docs.doc:abc123 or docs.folder:project-x. When a scope is provided, the client resolves its full ancestor chain and checks grants at the scope itself, at every ancestor, and at *.Scope ids hint rather than gate: a literal autocompletes every declared <scopeType>: prefix, while an id built from a variable flows through unchanged, because the instance half of a scope id is runtime data.
Omitting scope means the global scope, not “any scope”. can(p, key) asks may they do this everywhere?, the strictest question and the one only a global grant satisfies. The anywhere question is holds, and it is never a gate.
CheckOptions
Optional. { observe } controls whether this check emits a metrics observation. Defaults to true, and is inert unless the client was constructed with metrics. View-as previews pass false, so attribution never follows the preview.

Return value

Promise<boolean>
Resolves to true when the principal holds the key at the given scope (directly or through a covering grant higher in the hierarchy), and false in every other case including inactive principals, expired grants, and suppressed grants. This method never throws for a denial, only require throws.

can.fresh(principal, key, scope?)

can.fresh has the same signature as can but bypasses both the subject closure cache and the object ancestor-chain cache, fetching fresh data from the provider on every call.
Use can.fresh in place of can for:
  • Destructive actions (delete, purge, destroy_*) where acting on a stale “yes” is irreversible.
  • Just-in-time elevations. Time-limited grants where the expiry boundary matters at the second level, not the minute level.
  • Any endpoint where a user has just had access revoked and you need to see that revocation immediately rather than waiting for the TTL to expire.
Pair can.fresh with standalone destructive permission leaves. When your catalog declares delete as its own leaf rather than bundling it with update, the fresh check only pays the cost of a single destructive surface, and not every check on the page.

require(principal, key, scope?)

The throwing form of can. Identical semantics: it uses the cached path (not fresh), verifies the key against the catalog, and then throws AccessDeniedError if the principal does not hold the permission. Use this in server actions and route handlers where a thrown error is the right control-flow signal. AccessDeniedError carries a typed reason field:
Map AccessDeniedError to a 403 or a redirect. If you catch UnknownPermissionError is thrown when the key is not in the catalog. Map it to a 500. These are two distinct error classes: one is a runtime denial, the other is a programming mistake.

Cache semantics

Alfiz caches two things independently, both parameterized by provider invalidation events:
  • Subject closure. The principal’s group memberships, org memberships, and implicit groups. Cached for subjectCacheTtlMs (default 30 s) and bust immediately when the provider emits a user, subject, role, or all invalidation event.
  • Object ancestor chain. The ancestry path from a scope instance up to *. Cached for objectCacheTtlMs (default 60 s) and bust immediately when the provider emits a scope event for any scope in the chain.
Neither the grant evaluation result nor the final boolean is ever cached. Every can call re-evaluates the decision from freshly obtained (or cache-hit) closures. can.fresh bypasses both caches and always fetches from the provider, regardless of what is cached.

Examples

Basic global-scope gate

Scoped gate: checking access to a specific document

Using require in a server action

Fresh check for a destructive action

Checking any of multiple keys