N-05: the conditional-validator key was a colon join over components that may themselves contain colons, so two distinct valid bindings could collide and one definition's ETag could be prepared for another. The key is now a validated, byte-bounded fixed tuple encoded with JSON.stringify. N-09: capture localStorage exactly once and compare StorageEvent.storageArea against that object identity, so a pulse from sessionStorage or any other area is rejected instead of matching on key and value alone. The pulse key is registered in the storage registry as CACHE_INVALIDATION_PULSE. N-10: race loadPage against the caller signal and re-check before observing a page, so a non-cooperative loader can neither hold loadAll forever nor have a post-abort completion accumulated into a successful result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
153 lines
4.5 KiB
TypeScript
153 lines
4.5 KiB
TypeScript
import type { CacheScopeSnapshot } from "../../contracts/server-state-scope.ts";
|
|
|
|
export type ConditionalValidatorBinding = Readonly<{
|
|
definitionId: string;
|
|
identityToken: string;
|
|
representationVersion: number;
|
|
scope: CacheScopeSnapshot;
|
|
}>;
|
|
|
|
export type ConditionalValidatorStore = Readonly<{
|
|
install(
|
|
binding: ConditionalValidatorBinding,
|
|
validator: string,
|
|
cacheRevision: number,
|
|
): boolean;
|
|
prepare(
|
|
binding: ConditionalValidatorBinding,
|
|
cacheRevision: number,
|
|
): string | null;
|
|
acceptNotModified(
|
|
binding: ConditionalValidatorBinding,
|
|
cacheRevision: number,
|
|
hasMappedValue: boolean,
|
|
): boolean;
|
|
remove(binding: ConditionalValidatorBinding): void;
|
|
clear(): void;
|
|
}>;
|
|
|
|
type ValidatorRow = {
|
|
validator: string;
|
|
cacheRevision: number;
|
|
generation: number;
|
|
};
|
|
|
|
type ConditionalValidatorKeyTuple = readonly [
|
|
scopeFingerprint: string,
|
|
definitionId: string,
|
|
identityToken: string,
|
|
representationVersion: number,
|
|
];
|
|
|
|
/** The store is a trust boundary, so key components are validated and bounded. */
|
|
const MAX_KEY_COMPONENT_BYTES = 512;
|
|
const KEY_COMPONENT_ENCODER = new TextEncoder();
|
|
|
|
function isBoundedKeyComponent(value: unknown): value is string {
|
|
return (
|
|
typeof value === "string" &&
|
|
value.length > 0 &&
|
|
KEY_COMPONENT_ENCODER.encode(value).byteLength <=
|
|
MAX_KEY_COMPONENT_BYTES
|
|
);
|
|
}
|
|
|
|
export function createConditionalValidatorStore(
|
|
maxEntries = 1_024,
|
|
): ConditionalValidatorStore {
|
|
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
throw new TypeError("Invalid conditional validator capacity.");
|
|
}
|
|
const rows = new Map<string, ValidatorRow>();
|
|
|
|
/**
|
|
* N-05. A delimiter join is not injective here: `definitionId`,
|
|
* `identityToken` and the scope fingerprint may all contain the delimiter, so
|
|
* two distinct valid bindings could encode to the same key and one
|
|
* definition's ETag could be sent for another. The key is a validated fixed
|
|
* tuple encoded with `JSON.stringify`, which escapes the separators.
|
|
*/
|
|
function key(binding: ConditionalValidatorBinding): string | null {
|
|
if (
|
|
!binding.scope.isCurrent() ||
|
|
!isBoundedKeyComponent(binding.scope.fingerprint) ||
|
|
!isBoundedKeyComponent(binding.definitionId) ||
|
|
!/^[A-Za-z0-9._:-]{16,128}$/.test(binding.identityToken) ||
|
|
!Number.isSafeInteger(binding.representationVersion) ||
|
|
binding.representationVersion < 1
|
|
) {
|
|
return null;
|
|
}
|
|
const tuple: ConditionalValidatorKeyTuple = [
|
|
binding.scope.fingerprint,
|
|
binding.definitionId,
|
|
binding.identityToken,
|
|
binding.representationVersion,
|
|
];
|
|
return JSON.stringify(tuple);
|
|
}
|
|
|
|
return Object.freeze({
|
|
install(binding, validator, cacheRevision) {
|
|
const selectedKey = key(binding);
|
|
if (
|
|
!selectedKey ||
|
|
!isSafeEntityTag(validator) ||
|
|
!Number.isSafeInteger(cacheRevision) ||
|
|
cacheRevision < 0
|
|
) {
|
|
return false;
|
|
}
|
|
if (!rows.has(selectedKey) && rows.size >= maxEntries) return false;
|
|
rows.set(selectedKey, {
|
|
validator,
|
|
cacheRevision,
|
|
generation: binding.scope.generation,
|
|
});
|
|
return true;
|
|
},
|
|
prepare(binding, cacheRevision) {
|
|
const selectedKey = key(binding);
|
|
if (!selectedKey) return null;
|
|
const row = rows.get(selectedKey);
|
|
return row &&
|
|
row.generation === binding.scope.generation &&
|
|
row.cacheRevision === cacheRevision
|
|
? row.validator
|
|
: null;
|
|
},
|
|
acceptNotModified(binding, cacheRevision, hasMappedValue) {
|
|
const selectedKey = key(binding);
|
|
if (!selectedKey || !hasMappedValue) return false;
|
|
const row = rows.get(selectedKey);
|
|
return Boolean(
|
|
row &&
|
|
row.generation === binding.scope.generation &&
|
|
row.cacheRevision === cacheRevision,
|
|
);
|
|
},
|
|
remove(binding) {
|
|
const selectedKey = key(binding);
|
|
if (selectedKey) rows.delete(selectedKey);
|
|
},
|
|
clear() {
|
|
rows.clear();
|
|
},
|
|
});
|
|
}
|
|
|
|
function isSafeEntityTag(value: string): boolean {
|
|
if (value.length < 3 || value.length > 256) return false;
|
|
const opaque = value.startsWith('W/"')
|
|
? value.slice(3, -1)
|
|
: value.startsWith('"')
|
|
? value.slice(1, -1)
|
|
: null;
|
|
if (opaque === null || !value.endsWith('"')) return false;
|
|
return [...opaque].every((character) => {
|
|
const code = character.codePointAt(0) ?? 0;
|
|
return code === 0x21 || (code >= 0x23 && code <= 0x7e) ||
|
|
(code >= 0x80 && code <= 0xff);
|
|
});
|
|
}
|