Skip to main content
Every check in Alfiz flows through one of two shapes: can is the authorization gate, and canAny is the visibility affordance. A third form, can.fresh, carries the same signature as can but bypasses every cache. It exists because bounded staleness is acceptable on most reads but never on destructive writes. Understanding which shape to reach for, and when, is the core of using Alfiz correctly.

can(principal, key, scope?)

can is the only valid gate shape. Call it before performing any state-changing operation or returning protected data.
principal is a PrincipalRef, a discriminated union:
Pass the shape that matches your session: { userId: session.userId } for user sessions, { serviceId: "export-worker" } for machine callers. key is a typed template-literal derived from your catalog. The TypeScript compiler rejects any string that is not a declared leaf. You can also pass a readonly array of keys, and can returns true when the principal holds any one of them:
scope is an optional ScopeId: the resource instance being accessed (e.g., "docs.doc:123", "docs.folder:9"). Omit it for global checks. When a principal holds the key at any ancestor of the scope, can returns true, because hierarchy is resolved at check time. can returns Promise<boolean>. It never throws on a denied check; use require when you want a throw instead.
Every call to can is verified against the catalog before evaluation. If the key is not declared, Alfiz throws UnknownPermissionError, a programming error and not a denial. Map it to a 500, never to a 403.

can.fresh(principal, key, scope?)

can.fresh carries exactly the same signature as can but bypasses all caches: subject closures are not read from the cache, and object ancestor chains are re-fetched from the provider.
When to use can.fresh:
  • Destructive actions. Deleting, publishing, or transferring a resource where a stale allow would be worse than a brief latency hit.
  • Just-in-time elevations. The principal was just granted emergency access and must be able to act on it immediately, without waiting for a cache TTL.
Why it exists, and the staleness bounds: Subject-side closures are cached for subjectCacheTtlMs (default 30 seconds). A revocation can therefore take up to 30 seconds to propagate to can responses. On most read paths that bound is acceptable. On destructive paths it is not, and can.fresh is the explicit escape hatch.
Pair can.fresh with destructive server actions as a convention: can.fresh makes the staleness contract visible at the call site, so reviewers know the action is being explicit about freshness.

canAny(principal, pattern)

canAny is the visibility affordance. It answers the question: does this principal hold anything matching pattern, at any scope? Use it to show or hide navigation items and section headings.
pattern is a wildcard string derived from the catalog’s $pattern union, for example "docs.*" or "docs.files.*". Passing an undeclared pattern throws UnknownPermissionError.
canAny is never a gate. It tells you whether a section is worth showing. It does not authorize any action. The alfiz-verify static verifier emits an error whenever canAny appears inside a server action or route handler. Gate on a concrete key with can or require.

require and requireAny

require and requireAny are the throwing forms of can and canAny. They return Promise<void> and throw AccessDeniedError when the check fails.
AccessDeniedError carries a typed reason field:
Use require at the top of server actions and route handlers where you want a single thrown value to carry all downstream error handling. Use the boolean can when you need to branch without catching.

snapshot(principal, options?): one round-trip, synchronous checks

Server-rendered frameworks make hundreds of conditional-UI checks inside .map() callbacks and pure helpers that cannot be async. snapshot solves this: one provider round-trip, then every subsequent check is synchronous.
A snapshot is a stronger consistency guarantee than repeated can calls: every check in a request sees one subject-data instant and one evaluation clock. The caches do not tick over mid-render. List pages. When you do not know your row scope IDs until after the query:
Pass { fresh: true } to bypass caches at snapshot time, the same way can.fresh does for individual checks.
Flat scope types, meaning those declared parent: null and not multiParent, are always synchronously resolvable from a snapshot. Hierarchical scope types require pre-resolution, either via client.snapshot(principal, { scopes: [...] }) up front, or await snap.resolve([...]) once the IDs are known. Checking an unresolved hierarchical scope throws, rather than silently evaluating a truncated chain.

Staleness reference

can.fresh bypasses both layers. For moves your application makes in its own tables, call app.notifyScopeMoved(scope) from the same code path that changes the parent pointer. The TTL is the backstop and not the primary mechanism.