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:
{ 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.
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.
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.
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.
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:
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.
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:
{ 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.