import { isCacheInvalidationOpaqueIdentifier, type CacheInvalidationTopicDefinition, } from "../../contracts/cache-invalidation.ts"; import { STORAGE_REGISTRY } from "../../contracts/storage-keys.ts"; import { createBrowserCrossContextInvalidation, type BroadcastChannelFacade, type BroadcastMessageListener, type BrowserCrossContextInvalidation, type CrossContextInvalidationObservation, type StorageEventTargetFacade, type StoragePulseFacade, type StoragePulseListener, } from "./browser-cross-context-invalidation.ts"; export type BrowserCrossContextHostDependencies = Readonly<{ host?: Record; cacheEpoch: string; topics: readonly CacheInvalidationTopicDefinition[]; observe?: (observation: CrossContextInvalidationObservation) => void; }>; type NativeBroadcastChannel = Readonly<{ postMessage(value: unknown): void; addEventListener(type: string, listener: (event: unknown) => void): void; removeEventListener( type: string, listener: (event: unknown) => void, ): void; close(): void; }>; const CHANNEL_NAME = "ca-client-cache-invalidation-v1"; // N-09. The registry owns the physical-key policy for the pulse. const STORAGE_PULSE_KEY = STORAGE_REGISTRY.CACHE_INVALIDATION_PULSE.physicalKey; /** * Captures native capabilities without allowing a SecurityError getter or a * missing random source to fail application boot. */ export function createBrowserCrossContextInvalidationFromHost( dependencies: BrowserCrossContextHostDependencies, ): BrowserCrossContextInvalidation | undefined { // A zero-feature build owns no cross-context invalidation runtime. Preserve // that property strictly: do not even probe browser capability getters. if (dependencies.topics.length === 0) return undefined; const host = dependencies.host ?? (globalThis as unknown as Record); if ( !isCacheInvalidationOpaqueIdentifier(dependencies.cacheEpoch) ) { return undefined; } const createOpaqueId = randomIdFactory(host); if (!createOpaqueId) return undefined; const sourceId = createOpaqueId("tab"); const sourceEpoch = createOpaqueId("page"); if (!sourceId || !sourceEpoch) return undefined; const capturedLocalStorage = captureLocalStorage(host); return createBrowserCrossContextInvalidation({ channelName: CHANNEL_NAME, storagePulseKey: STORAGE_PULSE_KEY, sourceId, sourceEpoch, cacheEpoch: dependencies.cacheEpoch, topics: dependencies.topics, createEventId: () => { const eventId = createOpaqueId("event"); if (!eventId) throw new TypeError("Secure random is unavailable."); return eventId; }, createBroadcastChannel: broadcastFactory(host), // N-09. One capture, one identity: the write facade and the event // validator must agree about which Storage object they trust. Reading the // getter twice would let a hostile host return a different object. storage: capturedLocalStorage ? storageFacade(capturedLocalStorage) : undefined, storageEvents: storageEventTarget(host, capturedLocalStorage), observe: dependencies.observe, }); } function safeGet( target: Record, property: string, ): unknown { try { return Reflect.get(target, property); } catch { return undefined; } } function randomIdFactory( host: Record, ): ((prefix: string) => string | null) | undefined { const cryptoCandidate = safeGet(host, "crypto"); if (!cryptoCandidate || typeof cryptoCandidate !== "object") { return undefined; } const randomUuid = safeGet( cryptoCandidate as Record, "randomUUID", ); if (typeof randomUuid !== "function") return undefined; return (prefix) => { try { const value = Reflect.apply(randomUuid, cryptoCandidate, []); if (typeof value !== "string") return null; const candidate = `${prefix}.${value}`; return isCacheInvalidationOpaqueIdentifier(candidate) ? candidate : null; } catch { return null; } }; } function broadcastFactory( host: Record, ): | ((name: string) => BroadcastChannelFacade) | undefined { const Constructor = safeGet(host, "BroadcastChannel"); if (typeof Constructor !== "function") return undefined; return (name) => { const candidate = Reflect.construct(Constructor, [name]) as unknown; if (!isNativeBroadcastChannel(candidate)) { throw new TypeError("BroadcastChannel is incompatible."); } const listenerBindings = new Map< BroadcastMessageListener, (event: unknown) => void >(); return Object.freeze({ postMessage(value: unknown) { candidate.postMessage(value); }, addEventListener( _type: "message", listener: BroadcastMessageListener, ) { const bound = (event: unknown) => { listener({ data: event && typeof event === "object" ? safeGet( event as Record, "data", ) : undefined, }); }; listenerBindings.set(listener, bound); candidate.addEventListener("message", bound); }, removeEventListener( _type: "message", listener: BroadcastMessageListener, ) { const bound = listenerBindings.get(listener); if (!bound) return; listenerBindings.delete(listener); candidate.removeEventListener("message", bound); }, close() { listenerBindings.clear(); candidate.close(); }, }); }; } function isNativeBroadcastChannel( value: unknown, ): value is NativeBroadcastChannel { if (!value || typeof value !== "object") return false; const candidate = value as Record; return ["postMessage", "addEventListener", "removeEventListener", "close"].every( (method) => typeof safeGet(candidate, method) === "function", ); } function captureLocalStorage( host: Record, ): object | undefined { const candidate = safeGet(host, "localStorage"); return candidate && typeof candidate === "object" ? candidate : undefined; } function storageFacade( candidate: object, ): StoragePulseFacade | undefined { const record = candidate as Record; const setItem = safeGet(record, "setItem"); const removeItem = safeGet(record, "removeItem"); if (typeof setItem !== "function" || typeof removeItem !== "function") { return undefined; } return Object.freeze({ setItem(key, value) { Reflect.apply(setItem, candidate, [key, value]); }, removeItem(key) { Reflect.apply(removeItem, candidate, [key]); }, }); } function storageEventTarget( host: Record, expectedLocalStorage: object | undefined, ): StorageEventTargetFacade | undefined { const addEventListener = safeGet(host, "addEventListener"); const removeEventListener = safeGet(host, "removeEventListener"); if ( typeof addEventListener !== "function" || typeof removeEventListener !== "function" ) { return undefined; } const bindings = new Map< StoragePulseListener, (event: unknown) => void >(); return Object.freeze({ addEventListener(_type: "storage", listener: StoragePulseListener) { const bound = (event: unknown) => { if (!event || typeof event !== "object") { listener({ key: null, newValue: null, storageArea: "OTHER_OR_UNKNOWN", }); return; } const record = event as Record; const key = safeGet(record, "key"); const newValue = safeGet(record, "newValue"); const storageArea = safeGet(record, "storageArea"); listener({ key: typeof key === "string" ? key : null, newValue: typeof newValue === "string" ? newValue : null, // Compared by object identity against the captured area, never by // shape or by re-reading `host.localStorage`. storageArea: expectedLocalStorage !== undefined && storageArea === expectedLocalStorage ? "EXPECTED_LOCAL_STORAGE" : "OTHER_OR_UNKNOWN", }); }; bindings.set(listener, bound); Reflect.apply(addEventListener, host, ["storage", bound]); }, removeEventListener( _type: "storage", listener: StoragePulseListener, ) { const bound = bindings.get(listener); if (!bound) return; bindings.delete(listener); Reflect.apply(removeEventListener, host, ["storage", bound]); }, }); }