import type { LegacyApiKeySimple } from "@hivegrid/db/api-keys.server"; import { createTwoTierCache, type CacheSerializer, type SharedStore, type TwoTierCache } from "@hivegrid/shared-store"; import { getSharedStore } from "~/shared-store.server"; import { createScopedLogger } from "~/utils/logger.server"; const log = createScopedLogger("api-key-auth-cache"); // Pub/sub is the primary revocation path. The short TTL bounds exposure when a // replica misses an invalidation while disconnected from Redis. export const API_KEY_AUTH_CACHE_TTL_SECONDS = 30; type SerializedApiKeySnapshot = Omit & { expires: string | null }; const apiKeySerializer: CacheSerializer = { serialize(value) { const serialized: SerializedApiKeySnapshot = { ...value, expires: value.expires?.toISOString() ?? null, }; return JSON.stringify(serialized); }, deserialize(raw) { const value = JSON.parse(raw) as SerializedApiKeySnapshot; return { ...value, expires: value.expires ? new Date(value.expires) : null, }; }, }; function pubsubEnabledFromEnv(): boolean { const raw = process.env.CACHE_INVALIDATION_ENABLED; if (raw === undefined || raw === null || raw === "") return true; return raw.toLowerCase() !== "false" && raw !== "0"; } export function createApiKeyAuthCache(store: SharedStore, options: { now?: () => number } = {}): TwoTierCache { return createTwoTierCache({ store, namespace: "api-key-auth", ttlSeconds: API_KEY_AUTH_CACHE_TTL_SECONDS, l1MaxEntries: 10_000, l1Now: options.now, serializer: apiKeySerializer, pubsubEnabled: pubsubEnabledFromEnv(), logger: { warn: (obj, msg) => log.warn(obj, msg), info: (obj, msg) => log.info(obj, msg), error: (obj, msg) => log.error(obj, msg), }, }); } let cacheSingleton: TwoTierCache | null = null; let subscribeDisposer: (() => Promise) | null = null; export function getApiKeyAuthCache(): TwoTierCache { cacheSingleton ??= createApiKeyAuthCache(getSharedStore()); return cacheSingleton; } /** * Cache an already-verified authorization snapshot. The raw credential is * neither part of LegacyApiKeySimple nor passed to this module; callers use a * SHA-256 lookup key so Redis never receives plaintext API keys. */ export async function loadCachedApiKeySnapshot( lookupCacheKey: string, getFreshValue: () => Promise ): Promise { const cache = getApiKeyAuthCache(); try { const cached = await cache.get(lookupCacheKey); if (cached !== null) return cached; } catch (error) { log.warn( { op: "api-key-auth-cache.read", err: { name: error instanceof Error ? error.name : "Error" } }, "API-key cache read failed; falling back to the database." ); } const fresh = await getFreshValue(); if (fresh !== null) { try { await cache.set(lookupCacheKey, fresh); } catch (error) { log.warn( { op: "api-key-auth-cache.write", err: { name: error instanceof Error ? error.name : "Error" } }, "API-key cache write failed; continuing with the database result." ); } } return fresh; } /** Fail closed after key rotation, revocation, or deletion. */ export async function invalidateApiKeySnapshot(lookupCacheKey: string): Promise { const cache = getApiKeyAuthCache(); cache.deleteLocal(lookupCacheKey); await cache.l2.invalidate(lookupCacheKey); } export async function subscribeApiKeyAuthCache(): Promise<() => Promise> { if (subscribeDisposer) return subscribeDisposer; subscribeDisposer = await getApiKeyAuthCache().subscribe(); log.info({ namespace: "api-key-auth" }, "API-key authorization cache subscriber started."); return subscribeDisposer; } /** Test seam. */ export function __resetApiKeyAuthCacheForTests(): void { cacheSingleton = null; subscribeDisposer = null; }