The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
277 lines
8.5 KiB
TypeScript
277 lines
8.5 KiB
TypeScript
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<string, unknown>;
|
|
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<string, unknown>);
|
|
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<string, unknown>,
|
|
property: string,
|
|
): unknown {
|
|
try {
|
|
return Reflect.get(target, property);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function randomIdFactory(
|
|
host: Record<string, unknown>,
|
|
): ((prefix: string) => string | null) | undefined {
|
|
const cryptoCandidate = safeGet(host, "crypto");
|
|
if (!cryptoCandidate || typeof cryptoCandidate !== "object") {
|
|
return undefined;
|
|
}
|
|
const randomUuid = safeGet(
|
|
cryptoCandidate as Record<string, unknown>,
|
|
"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<string, unknown>,
|
|
):
|
|
| ((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<string, unknown>,
|
|
"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<string, unknown>;
|
|
return ["postMessage", "addEventListener", "removeEventListener", "close"].every(
|
|
(method) => typeof safeGet(candidate, method) === "function",
|
|
);
|
|
}
|
|
|
|
function captureLocalStorage(
|
|
host: Record<string, unknown>,
|
|
): 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<string, unknown>;
|
|
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<string, unknown>,
|
|
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<string, unknown>;
|
|
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]);
|
|
},
|
|
});
|
|
}
|