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 { 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 { 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)) ); }