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>
205 lines
6.8 KiB
TypeScript
205 lines
6.8 KiB
TypeScript
import type {
|
|
BrowserDataFailureCode,
|
|
BrowserDataOperation,
|
|
} from "../../application/ports/browser-file-storage/shared.ts";
|
|
|
|
export type PublicCacheRuntimePolicy = Readonly<{
|
|
origin: string;
|
|
ownedCachePrefix: string;
|
|
mutationLockName: string;
|
|
maxEntryBytes: number;
|
|
maxReleaseBytes: number;
|
|
maxEntriesPerRelease: number;
|
|
retainedPreviousReleaseCount: number;
|
|
allowedRequestHeaderNames: readonly string[];
|
|
allowedVaryHeaderNames: readonly string[];
|
|
allowedResponseHeaderNames: readonly string[];
|
|
unknownResponseHeaderAction: "REJECT" | "STRIP";
|
|
allowedQueryParameterNames: readonly string[];
|
|
forbiddenQueryParameterNames: readonly string[];
|
|
isQueryParameterValueAllowed: (name: string, value: string) => boolean;
|
|
isContentTypeAllowed: (contentType: string) => boolean;
|
|
isReleaseRegistryIdAllowed: (releaseRegistryId: string) => boolean;
|
|
}>;
|
|
|
|
export type PublicCacheSafeObservation = Readonly<{
|
|
operation: BrowserDataOperation;
|
|
outcome: "STARTED" | "SUCCEEDED" | "FAILED";
|
|
failureCode?: BrowserDataFailureCode;
|
|
releaseRegistryId?: string;
|
|
byteBucket?: "0" | "1B_1MiB" | "1MiB_16MiB" | "GT_16MiB";
|
|
entryBucket?: "0" | "1_10" | "11_100" | "GT_100";
|
|
}>;
|
|
|
|
export type PublicCacheSafeObserver = (
|
|
observation: PublicCacheSafeObservation,
|
|
) => void;
|
|
|
|
const RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
const CACHE_PREFIX = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,63}:$/u;
|
|
const DEFAULT_PUBLIC_CONTENT_TYPE =
|
|
/^(?:application\/(?:javascript|json|manifest\+json|wasm)|font\/[a-z0-9.+-]+|image\/[a-z0-9.+-]+|text\/(?:css|javascript|plain))(?:\s*;.*)?$/iu;
|
|
|
|
export function createDefaultPublicCachePolicy(
|
|
origin: string,
|
|
): PublicCacheRuntimePolicy {
|
|
return resolvePublicCachePolicy({
|
|
origin,
|
|
ownedCachePrefix: "ca-public-v1:",
|
|
mutationLockName: "ca-public-v1:mutation",
|
|
maxEntryBytes: 16 * 1024 * 1024,
|
|
maxReleaseBytes: 128 * 1024 * 1024,
|
|
maxEntriesPerRelease: 500,
|
|
retainedPreviousReleaseCount: 1,
|
|
allowedRequestHeaderNames: ["accept", "accept-language"],
|
|
allowedVaryHeaderNames: [],
|
|
allowedResponseHeaderNames: [
|
|
"cache-control",
|
|
"content-language",
|
|
"content-type",
|
|
"etag",
|
|
"last-modified",
|
|
"vary",
|
|
],
|
|
unknownResponseHeaderAction: "STRIP",
|
|
allowedQueryParameterNames: [],
|
|
forbiddenQueryParameterNames: [
|
|
"access_token",
|
|
"api_key",
|
|
"auth",
|
|
"email",
|
|
"jwt",
|
|
"session",
|
|
"token",
|
|
"user",
|
|
],
|
|
isQueryParameterValueAllowed: () => false,
|
|
isContentTypeAllowed: (contentType) =>
|
|
DEFAULT_PUBLIC_CONTENT_TYPE.test(contentType),
|
|
isReleaseRegistryIdAllowed: (releaseRegistryId) =>
|
|
RELEASE_ID.test(releaseRegistryId),
|
|
});
|
|
}
|
|
|
|
export function resolvePublicCachePolicy(
|
|
policy: PublicCacheRuntimePolicy,
|
|
): PublicCacheRuntimePolicy {
|
|
const normalized: PublicCacheRuntimePolicy = Object.freeze({
|
|
...policy,
|
|
origin: new URL(policy.origin).origin,
|
|
allowedRequestHeaderNames: Object.freeze(
|
|
policy.allowedRequestHeaderNames.map((name) => name.toLowerCase()),
|
|
),
|
|
allowedVaryHeaderNames: Object.freeze(
|
|
policy.allowedVaryHeaderNames.map((name) => name.toLowerCase()),
|
|
),
|
|
allowedResponseHeaderNames: Object.freeze(
|
|
policy.allowedResponseHeaderNames.map((name) => name.toLowerCase()),
|
|
),
|
|
allowedQueryParameterNames: Object.freeze(
|
|
policy.allowedQueryParameterNames.map((name) => name.toLowerCase()),
|
|
),
|
|
forbiddenQueryParameterNames: Object.freeze(
|
|
policy.forbiddenQueryParameterNames.map((name) => name.toLowerCase()),
|
|
),
|
|
});
|
|
assertPublicCachePolicy(normalized);
|
|
return normalized;
|
|
}
|
|
|
|
export function assertPublicCachePolicy(
|
|
policy: PublicCacheRuntimePolicy,
|
|
): void {
|
|
const origin = new URL(policy.origin);
|
|
if (
|
|
origin.origin !== policy.origin ||
|
|
!isAllowedPublicCacheOrigin(origin) ||
|
|
!CACHE_PREFIX.test(policy.ownedCachePrefix) ||
|
|
policy.mutationLockName.length === 0 ||
|
|
!positiveSafeInteger(policy.maxEntryBytes) ||
|
|
!positiveSafeInteger(policy.maxReleaseBytes) ||
|
|
policy.maxEntryBytes > policy.maxReleaseBytes ||
|
|
!positiveSafeInteger(policy.maxEntriesPerRelease) ||
|
|
policy.maxEntriesPerRelease > 10_000 ||
|
|
!Number.isSafeInteger(policy.retainedPreviousReleaseCount) ||
|
|
policy.retainedPreviousReleaseCount < 1 ||
|
|
policy.retainedPreviousReleaseCount > 5 ||
|
|
!headerNameList(policy.allowedRequestHeaderNames) ||
|
|
!headerNameList(policy.allowedVaryHeaderNames) ||
|
|
!headerNameList(policy.allowedResponseHeaderNames) ||
|
|
!["REJECT", "STRIP"].includes(policy.unknownResponseHeaderAction) ||
|
|
!queryNameList(policy.allowedQueryParameterNames) ||
|
|
policy.allowedVaryHeaderNames.some(
|
|
(name) => !policy.allowedRequestHeaderNames.includes(name),
|
|
) ||
|
|
// STO-03. Enabling variants while stripping `vary` from stored responses
|
|
// makes every variant collide on the same cache key, so the combination is
|
|
// rejected at composition instead of producing an unusable candidate.
|
|
(policy.allowedVaryHeaderNames.length > 0 &&
|
|
!policy.allowedResponseHeaderNames.includes("vary")) ||
|
|
policy.forbiddenQueryParameterNames.some((name) => name.length === 0) ||
|
|
policy.allowedQueryParameterNames.some((name) =>
|
|
policy.forbiddenQueryParameterNames.includes(name),
|
|
)
|
|
) {
|
|
throw new TypeError("Public Cache Storage policy is invalid.");
|
|
}
|
|
}
|
|
|
|
export function isAllowedPublicCacheOrigin(url: URL): boolean {
|
|
return (
|
|
url.protocol === "https:" ||
|
|
(url.protocol === "http:" &&
|
|
(url.hostname === "localhost" ||
|
|
url.hostname === "[::1]" ||
|
|
/^127(?:\.\d{1,3}){3}$/u.test(url.hostname)))
|
|
);
|
|
}
|
|
|
|
export function cacheByteBucket(
|
|
byteLength: number,
|
|
): NonNullable<PublicCacheSafeObservation["byteBucket"]> {
|
|
if (byteLength === 0) return "0";
|
|
if (byteLength <= 1024 * 1024) return "1B_1MiB";
|
|
if (byteLength <= 16 * 1024 * 1024) return "1MiB_16MiB";
|
|
return "GT_16MiB";
|
|
}
|
|
|
|
export function cacheEntryBucket(
|
|
count: number,
|
|
): NonNullable<PublicCacheSafeObservation["entryBucket"]> {
|
|
if (count === 0) return "0";
|
|
if (count <= 10) return "1_10";
|
|
if (count <= 100) return "11_100";
|
|
return "GT_100";
|
|
}
|
|
|
|
export function observePublicCacheSafely(
|
|
observer: PublicCacheSafeObserver | undefined,
|
|
observation: PublicCacheSafeObservation,
|
|
): void {
|
|
try {
|
|
observer?.(Object.freeze({ ...observation }));
|
|
} catch {
|
|
// Cache behavior never depends on observability.
|
|
}
|
|
}
|
|
|
|
function positiveSafeInteger(value: number): boolean {
|
|
return Number.isSafeInteger(value) && value > 0;
|
|
}
|
|
|
|
function headerNameList(names: readonly string[]): boolean {
|
|
return (
|
|
new Set(names).size === names.length &&
|
|
names.every((name) => /^[a-z0-9!#$%&'*+.^_`|~-]+$/u.test(name))
|
|
);
|
|
}
|
|
|
|
function queryNameList(names: readonly string[]): boolean {
|
|
return (
|
|
new Set(names).size === names.length &&
|
|
names.every((name) => /^[a-z0-9][a-z0-9._-]{0,63}$/u.test(name))
|
|
);
|
|
}
|