Skip to main content
createAlfizClient is the single entry point for the evaluator. It binds a catalog and a provider together into an AlfizClient whose can, canAny, and require* methods are typed against the exact key and pattern unions your catalog declares, so every call site is verified at compile time, and every runtime-string path is verified before evaluation. You call this once at application startup and share the resulting client across requests.

Signature

The client carries three derived type parameters: the catalog’s key union, its pattern union, and its scope-id union. ClientOf<Cat> is the alias for exactly that, so you can annotate a stored client without hand-writing any of them.
Keys and patterns gate: a typo at a literal call site is a compile error. Scope ids hint (LooseScopeId): a literal scope 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.

Options

AnyCatalog
required
The built catalog returned by defineCatalog. Every key and pattern passed to can, canAny, or any require* method is verified against this catalog before evaluation. Passing an undeclared key raises UnknownPermissionError, a programming error and not a denial.
AlfizProvider
required
The provider implementation. In the open-source packages this is the AlfizApplication returned by createApplication; Alfiz Cloud speaks the same contract. The client attaches to the provider’s invalidation stream on construction and detaches when you call client.close().
number
default:"30000"
How long (in milliseconds) a principal’s subject closure is cached before it is re-fetched from the provider. Subject closures are wide, high-churn data that tolerate seconds-to-minutes propagation delay. This is the documented staleness bound for revocation propagation; can.fresh is the escape hatch when you need an immediate check.
number
default:"60000"
How long (in milliseconds) a scope’s ancestor chain is cached. Object chains are narrow and near-static; provider scope invalidation events bust them immediately when a resource moves. This TTL bounds staleness only when a move was never reported via notifyScopeMoved.
number
default:"10000"
The maximum number of principals kept in the subject cache. When this limit is exceeded, the least-recently-used entries are evicted. Tune this to match your active-user count, because eviction causes a re-fetch on the next check for the evicted principal.
number
default:"10000"
The maximum number of scope chains kept in the object cache. When this limit is exceeded, the least-recently-used entries are evicted. Tune this to match your active-scope working set.
number | false
default:"5000 (with an epoch)"
Cross-process revalidation window in milliseconds. Against a provider that persists its invalidation log (the Application’s events.persist, itself on by default since 0.7.0), the client validates its caches with one constant-cost read of the log head per window, shared across every concurrent check. An unchanged head renews TTLs; a changed head replays only the missed events through the same busting logic the live stream feeds. Effective cross-process staleness becomes this window rather than the blind TTL. Fail-closed: an unreachable epoch degrades to the pre-epoch TTL contract, never below it.On by default (5 000 ms) when the provider exposes an epoch. The safe configuration is the default one since 0.7.0. Pass a number to choose the window, or false to opt out and return to TTL-only caching. Inert against a provider with no epoch.
boolean
default:"false"
The incident switch: true makes every check on every surface bypass both cache tiers, taking a fresh closure supply and fresh ancestry, per check. exactly as if each call were can.fresh. Propagation bounds collapse to zero; every check pays provider round-trips. Wire it to an environment variable and flip it during a security incident, then flip it back. See the incident runbook.
CacheStore
Optional shared cache tier (L2) between the in-process caches and the provider. Read order is L1 → L2 → provider. The win is cold processes (serverless invocations, fresh deploys) finding a warm closure instead of paying the full fan-out. Entries are served only when provably fresh. under the current event-log head with revalidateAfterMs on, or within cacheStoreTtlMs without it, and every L2 failure is a miss, never an answer. Writes are fire-and-forget. The store holds closure data inside the server trust boundary: point it only at authenticated, private cache infrastructure. Use respCacheStore(client) to adapt any RESP-family client (node-redis or ioredis call shape, so Redis, Valkey, KeyDB, Dragonfly, ElastiCache, Upstash) with no added dependency.
string
default:"alfiz:v1:"
Key prefix for L2 entries. Change this only if a single cache instance is shared across incompatible Alfiz deployments.
number
Storage TTL for L2 entries. With revalidateAfterMs set, this only bounds storage growth (freshness comes from the sequence check) and defaults to 10 minutes. Without an epoch it is the freshness bound, and defaults to a single value, the larger of the two L1 TTLs, Math.max(subjectCacheTtlMs, objectCacheTtlMs), so 60 000 ms at the defaults.
(error: unknown) => void
Observes swallowed L2 errors for metrics and logging. Failures are misses either way.
MetricsOptions
Emit a structured CheckObservation per evaluated check, carrying shape, decision, permission, scope type, principal, and the rows that decided it, to an observer you supply. Off by default. Point it at otelMetricsObserver({ meter }), createMetricsAggregator(), or createProviderMetricsSink(app). Observations are built only after the sampleRate draw passes, observers are invoked fire-and-forget, and an observer that throws can never fail a check. See Permission metrics.
"error" | "warn" | "allow"
default:"\"error\""
What to do with a check for a permission in a namespace this catalog neither owns nor imports, an implicit import. "error" throws UnknownPermissionError, as it always has; "warn" evaluates it and reports once per distinct permission; "allow" evaluates it silently.Two cases never soften, whatever you set: a permission under a namespace you own (your catalog is enumerable, so an unknown key in it is a typo) and one outside an enumerated import (that import knows its own keys, and says which). And nothing here performs I/O: no provider lookup, no fetch. snapshot.can() is synchronous, which is what makes the decision structurally local rather than merely conventionally so.A permission admitted this way is declared in no catalog, so a bare global * grant does not confer it; a grant naming the namespace does.
(info: ExternalPermissionInfo) => void
Called once per distinct permission admitted under externalPermissions: "warn". Replaces the default console.warn; point it at your logger or your metrics. Checks admitted this way also carry externalPermission: true in the metrics observation stream, so you can count what still needs declaring.
() => number
Optional. A function that returns the current time as epoch milliseconds. Defaults to Date.now. Override this in tests to control grant expiry without manipulating wall time.

Return value

object
A fully initialized client. The type parameters K, P, and S are the catalog’s derived key, pattern, and scope-id unions, inferred at the createAlfizClient call site. Store this as ClientOf<typeof catalog> on your application context.

ClientOf<Cat>: the stored-client type alias

When you attach the client to a framework context object or export it from a module, annotate it with ClientOf rather than spelling out the full generic:

client.close()

Unsubscribes from the provider’s invalidation stream, clears the subject and object caches, and cancels any in-flight fetches. Call this when the server is shutting down, or when you hot-reload the client after a catalog republish. There is no closed flag: a check made after close() still evaluates correctly, it has simply lost event-driven invalidation and falls back to TTL-only freshness. Construct a new client rather than relying on that.

Complete initialization example

createAlfizClient attaches to the provider’s invalidation stream immediately on construction. The stream delivers cache-bust events whenever a grant, membership, or scope move is written through the provider, so you do not need to manage cache invalidation manually.

Cross-process caching (multi-node and serverless)

The in-process invalidation stream only reaches the process that wrote the event, so in a multi-node or serverless deployment the TTLs alone would bound how quickly a revocation on one instance takes effect on another. revalidateAfterMs tightens that bound to a window by validating the caches against the durable event log the Application maintains, and since 0.7.0 both halves are on by default: the Application persists events whenever its driver can, and the client revalidates whenever its provider exposes an epoch. The example below spells the options out anyway; against the bundled drivers it is what you get with no options at all. Turn on the log on the Application, then set a revalidation window on the client:
With this configured:
  • Long-lived nodes validate caches once per window with a single-row read whose cost is independent of organization size. Quiet systems stop refetching entirely (TTLs are renewed); a write anywhere propagates within one window.
  • Serverless invocations find warm closures in the shared L2 instead of paying the full closure fan-out on every cold start. Entries are only served when the L2 stamp matches the current log head.
Both features are opt-in and independent. See the Application’s events.persist option for the write-side setup.