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
ClientOf<Cat> is the alias for exactly that, so you can annotate a stored client without hand-writing any of them.
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()
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:
- 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.
events.persist option for the write-side setup.