Files
tech-log-frontend/src/adapters/cache-storage/public-cache-policy.ts
T
DongHyeonkaandClaude Opus 5 4bff9ca151 chore: sync the frontend template from 4dc033c to 8157ad4
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>
2026-08-15 12:04:58 +09:00

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