/** * DecisionCache — accidental-state port for authorization decision caching. * * Wraps the directCheckCache + ruleResultCache + reverse-indexes * + dependencyIndex + TTL settings that the engine uses to short-circuit * repeated authorization decisions. Implementing this port gives test * code an explicit, dependency-injectable seam — and frees the engine * from the rule that "function signature lies" (RF-03 closure). * * The port exposes ONLY the surface used by the engine: * - getDirect(key) / setDirect(key, result) * - getRule(ruleKey) / setRule(ruleKey, result) * - invalidateByRelation(relation) * - invalidateByNodeKey(nodeKey) // for NodeManager cache leak fix * - invalidateAll() * * All TTL / clock concerns move into the port implementation so the * AuthorizationChecker no longer reads wall-clock from its hot path. * * The default `ArbiterDecisionCache` implementation forwards every call * to the corresponding field on the Arbiter instance — preserving the * existing behavior of every test that inspects `arbiter.directCheckCache` * directly while moving the read site out of AuthorizationChecker. */ export class DecisionCache { /** * @param {object} arbiter The Arbiter instance whose caches we wrap. * `null` produces a fully inert cache (every * get returns undefined, every set is a no-op) * — useful for tests that want to disable caching * without constructing a full Arbiter. * @param {object} [options] * @param {(unit?:string) => number} [options.clock] Time source for TTL * checks. Defaults to Date.now (ms) but tests * can inject a fake clock. */ constructor(arbiter, options = {}) { this.arbiter = arbiter; // Precedence: an explicit cache-level clock wins; else the arbiter's // injected clock (options.clock on the Arbiter); else the wall clock. this.clock = options.clock || ((arbiter && typeof arbiter.clock === 'function') ? arbiter.clock.bind(arbiter) : (() => Date.now())); this._enabled = !!arbiter; } /** True if this cache can store anything. */ get enabled() { if (!this._enabled) return false; const a = this.arbiter; return !a.disableCaching; } /** True if direct (single-step) caching is active. */ get directEnabled() { if (!this.enabled) return false; return !!(this.arbiter.directCheckCache) && !this.arbiter.disableDirectCaching; } /** True if rule-result caching is active. */ get ruleEnabled() { if (!this.enabled) return false; return !!this.arbiter.ruleResultCache; } /** * Look up a previously-cached direct-check result. * Returns undefined on miss / disabled. * Distinguishes "expired" from "miss" via the second tuple element. * * @returns {[result, status]} status is 'hit' | 'expired' | 'miss' | 'disabled' */ peekDirect(cacheKey) { if (!this.directEnabled) return [undefined, 'disabled']; const entry = this.arbiter.directCheckCache.get(cacheKey); if (!entry) return [undefined, 'miss']; if (this.clock() - entry.timestamp >= this.arbiter.directCheckCacheTTL) { return [entry.result, 'expired']; } return [entry.result, 'hit']; } /** * Simplified form: returns just the result on hit, undefined otherwise. */ getDirect(cacheKey) { const [result, status] = this.peekDirect(cacheKey); return status === 'hit' ? result : undefined; } /** * Store a direct-check result. Caller is responsible for key construction * (so the cache key derivation logic stays in AuthorizationChecker where * the schema lives). */ setDirect(cacheKey, result) { if (!this.directEnabled) return; this.arbiter.directCheckCache.set(cacheKey, { result, timestamp: this.clock() }); } /** * Look up a previously-cached rule-evaluation result. * Returns undefined on miss / disabled / expired. */ getRule(ruleCacheKey) { if (!this.ruleEnabled) return undefined; const entry = this.arbiter.ruleResultCache.get(ruleCacheKey); if (!entry) return undefined; // A graph mutation since the entry was computed invalidates it (stale // derived result). _graphVersion increments on every relation mutation. if (entry.graphVersion !== undefined && entry.graphVersion !== (this.arbiter._graphVersion ?? 0)) { return undefined; } if (this.clock() - entry.timestamp >= this.arbiter.ruleResultCacheTTL) { return undefined; } return entry.result; } setRule(ruleCacheKey, result) { if (!this.ruleEnabled) return; this.arbiter.ruleResultCache.set(ruleCacheKey, { result, timestamp: this.clock(), graphVersion: this.arbiter._graphVersion ?? 0 }); } /** * Mark a cache key as belonging to a particular relation so that * future invalidateByRelation(relation) calls can find it. */ trackRuleKeyForRelation(relation, cacheKey) { const a = this.arbiter; if (!a.ruleResultCache || !cacheKey) return; let set = a.ruleResultCacheKeysByRelation.get(relation); if (!set) { set = new Set(); a.ruleResultCacheKeysByRelation.set(relation, set); } set.add(cacheKey); a.ruleResultCacheRelationByKey.set(cacheKey, relation); } /** * Invalidate every direct + rule-result cache entry that could * have been affected by a relation change. This mirrors the old * Arbiter._invalidateDirectCheckCache + invalidateRuleResultCacheByRelation * pair, fused into one port-level call. */ invalidateByRelation(relation) { const a = this.arbiter; if (!a) return; if (a.directCheckCache) { a._invalidateDirectCheckCache?.(/*srcKey*/ null, relation, /*dstKey*/ null); } a.invalidateRuleResultCacheByRelation?.(relation); } /** * Invalidate every direct-check entry whose composite key contains * a given node id. Replaces the leaky `cacheKey.includes(String(nodeId))` * walk that NodeManager used to perform. Now the cache file is the * only place that knows its own storage layout. * * Requires the cache implementation to expose either a key iterator * (`keys()`) or a pattern-invalidate method (`invalidateByPattern`). * The local @tenere/hyperbolic-lru@1.0.3 provides both. The previous * npm hyperbolic-lru@1.0.2 exposed neither, making this method a no-op. */ invalidateByNodeKey(nodeKey) { const a = this.arbiter; if (!a?.directCheckCache) return; const nodeId = a.nodeIdByKey?.get(nodeKey); if (nodeId === undefined) return; // Keys are rolling hashes (not pipe-delimited strings), so node-level // invalidation uses the per-node key index populated at cache-set time. a._invalidateDirectCheckCacheByNode?.(nodeId); } /** * Flush every cache. Equivalent to the old * `disableCaching = true` test path. */ invalidateAll() { const a = this.arbiter; if (!a) return; a.directCheckCache?.clear?.(); a.directCheckCacheKeysByRelation?.clear?.(); a.directCheckCacheKeysByNode?.clear?.(); a.directKeyToRelations?.clear?.(); a.invalidateAllRuleResultCache?.(); } } /** * NullDecisionCache — every operation is a no-op. Use in tests that want * a pure decision flow with no accidental state at all. */ export class NullDecisionCache { constructor() { this._enabled = true; } get enabled() { return false; } get directEnabled() { return false; } get ruleEnabled() { return false; } getDirect() { return undefined; } setDirect() {} getRule() { return undefined; } setRule() {} trackRuleKeyForRelation() {} invalidateByRelation() {} invalidateByNodeKey() {} invalidateAll() {} } export default DecisionCache;