@tokenoftrust/storefront-runner 2.2.24 → 2.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Owner operations on durable images (ADR 0038): the status of every designated image, and the
3
+ * explicit, recorded removal. Pure handlers over injected seams; the Astro routes in
4
+ * `pages/api/changes/durable-assets/` bind the session, tenant config and TENANT_CACHE.
5
+ *
6
+ * Removal is the ONLY way a durable image stops being durable. It refuses an entry the latest
7
+ * published declaration still lists (drop it from `content/durable-assets.json` and publish first),
8
+ * records `{entry, actorId, at}` with the owner's opaque actor id, and never deletes anything
9
+ * itself: both pool GCs release the hold once the removal is older than the normal grace.
10
+ */
11
+ import {
12
+ durableAssetsStatusKey,
13
+ durableStatusView,
14
+ parseDurableAssetsStatus,
15
+ removeDurableEntry,
16
+ } from "@tot/public-runtime/durable-assets";
17
+
18
+ export interface DurableRouteDeps {
19
+ tenantId: string;
20
+ servingProvider: string | null | undefined;
21
+ /** The tenant's public origin (`https://<canonical host>`), where `/_a/<entry>` is served. */
22
+ publicOrigin: string | null;
23
+ kv: { get(key: string): Promise<string | null>; put(key: string, value: string): Promise<void> };
24
+ }
25
+
26
+ function json(body: unknown, status = 200): Response {
27
+ return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
28
+ }
29
+
30
+ export async function handleDurableStatus(deps: DurableRouteDeps): Promise<Response> {
31
+ const raw = await deps.kv.get(durableAssetsStatusKey(deps.tenantId));
32
+ const record = parseDurableAssetsStatus(raw, deps.tenantId);
33
+ if (raw && !record) return json({ ok: false, error: "The durable image record could not be read." }, 503);
34
+ if (!record) return json({ ok: true, images: [], removals: [] });
35
+ return json({
36
+ ok: true,
37
+ images: durableStatusView(record, { provider: deps.servingProvider, publicOrigin: deps.publicOrigin }),
38
+ // Who removed what, and when — opaque actor ids only.
39
+ removals: record.removals,
40
+ });
41
+ }
42
+
43
+ export async function handleDurableRemove(deps: DurableRouteDeps & { actorId: string; body: unknown; now?: () => Date }): Promise<Response> {
44
+ const entry = (deps.body as { entry?: unknown } | null)?.entry;
45
+ if (typeof entry !== "string" || !/^[0-9a-f]{64}\.[A-Za-z0-9]+$/.test(entry)) {
46
+ return json({ ok: false, error: "entry must be the image's <sha256><ext> (the tail of its /_a/ URL)." }, 400);
47
+ }
48
+ const key = durableAssetsStatusKey(deps.tenantId);
49
+ const raw = await deps.kv.get(key);
50
+ const record = parseDurableAssetsStatus(raw, deps.tenantId);
51
+ if (!record) {
52
+ return json({ ok: false, error: raw ? "The durable image record could not be read." : "No durable images are designated." }, raw ? 503 : 404);
53
+ }
54
+ const decision = removeDurableEntry(record, { entry, actorId: deps.actorId, at: (deps.now ?? (() => new Date()))().toISOString() });
55
+ if (!decision.ok) {
56
+ const message = {
57
+ "unknown-entry": "That image is not designated as durable.",
58
+ "still-declared": "The published declaration still lists this image. Remove it from content/durable-assets.json and publish, then remove it here.",
59
+ "invalid-actor": "The removal could not be attributed to an owner.",
60
+ }[decision.reason];
61
+ return json({ ok: false, reason: decision.reason, error: message }, decision.reason === "unknown-entry" ? 404 : 409);
62
+ }
63
+ await deps.kv.put(key, JSON.stringify(decision.record));
64
+ return json({ ok: true, removed: entry, releasedAfter: "the normal collection grace (about one day)" });
65
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The Cloudflare (R2) half of captured-original staging and durable images (ADR 0038). Worker-safe:
3
+ * R2 bindings, KV and Web Crypto only. The S3 half is `scripts/publish/lib/hosted-original-publication*.mjs`;
4
+ * both obey the same hold, lease and status rules (`@tot/public-runtime/retention-holds`,
5
+ * `@tot/public-runtime/durable-assets`).
6
+ *
7
+ * Order, always: write the references (version manifest already in R2, or the holds), wait while a
8
+ * bundle-GC sweep holds its lease, then stage and verify every object those references name.
9
+ */
10
+ import { sha256Hex } from "@tot/public-runtime";
11
+ import { PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION } from "@tot/public-runtime/hosted-image-media-types";
12
+ import {
13
+ durableAssetsStatusKey,
14
+ durableCaptureRevisionId,
15
+ durableHoldKey,
16
+ emptyDurableAssetsStatus,
17
+ mergeDurableDesignations,
18
+ parseDurableAssetsStatus,
19
+ type DurableAssetRecord,
20
+ } from "@tot/public-runtime/durable-assets";
21
+ import { GC_LEASE_KEY, awaitGcQuiescence } from "@tot/public-runtime/retention-holds";
22
+
23
+ const MAX_IMAGE = 8 * 1024 * 1024;
24
+ const MAX_MANIFEST = 1024 * 1024;
25
+ const POOL_ENTRY_RE = /^([0-9a-f]{64})(\.[A-Za-z0-9]+)$/;
26
+ const IMMUTABLE = "public, max-age=31536000, immutable";
27
+
28
+ export interface R2ObjectLike {
29
+ size: number;
30
+ body?: ReadableStream<Uint8Array> | null;
31
+ arrayBuffer?(): Promise<ArrayBuffer>;
32
+ }
33
+
34
+ export interface R2BucketLike {
35
+ get(key: string): Promise<R2ObjectLike | null>;
36
+ put?(key: string, body: Uint8Array, options: {
37
+ httpMetadata: { contentType: string; cacheControl: string };
38
+ onlyIf?: Headers;
39
+ }): Promise<unknown>;
40
+ }
41
+
42
+ export interface StatusKvLike {
43
+ get(key: string): Promise<string | null>;
44
+ put(key: string, value: string): Promise<void>;
45
+ }
46
+
47
+ export type QuiescenceWaiter = (opts: { readLease(): Promise<Uint8Array | null> }) => Promise<{ waited: boolean }>;
48
+
49
+ export class R2StagingError extends Error {
50
+ constructor(readonly code: string) {
51
+ super(`Cloudflare original staging refused (${code}).`);
52
+ this.name = "R2StagingError";
53
+ }
54
+ }
55
+
56
+ export async function readBounded(bucket: R2BucketLike, key: string, limit: number): Promise<Uint8Array | null> {
57
+ const object = await bucket.get(key);
58
+ if (!object) return null;
59
+ if (!Number.isSafeInteger(object.size) || object.size < 0 || object.size > limit) return null;
60
+ if (!object.body) {
61
+ if (!object.arrayBuffer) return null;
62
+ const whole = new Uint8Array(await object.arrayBuffer());
63
+ return whole.byteLength === object.size ? whole : null;
64
+ }
65
+ const reader = object.body.getReader();
66
+ const chunks: Uint8Array[] = [];
67
+ let size = 0;
68
+ try {
69
+ for (;;) {
70
+ const { done, value } = await reader.read();
71
+ if (done) break;
72
+ size += value.byteLength;
73
+ if (size > object.size || size > limit) {
74
+ await reader.cancel();
75
+ return null;
76
+ }
77
+ chunks.push(value);
78
+ }
79
+ } finally {
80
+ reader.releaseLock();
81
+ }
82
+ if (size !== object.size) return null;
83
+ const bytes = new Uint8Array(size);
84
+ let offset = 0;
85
+ for (const chunk of chunks) {
86
+ bytes.set(chunk, offset);
87
+ offset += chunk.byteLength;
88
+ }
89
+ return bytes;
90
+ }
91
+
92
+ /** Wait while a bundle-GC sweep holds its lease in `bucket`. */
93
+ export function awaitR2Quiescence(bucket: R2BucketLike, waiter: QuiescenceWaiter = awaitGcQuiescence): Promise<{ waited: boolean }> {
94
+ return waiter({ readLease: () => readBounded(bucket, GC_LEASE_KEY, 64 * 1024) });
95
+ }
96
+
97
+ /** Present with exactly `bytes`, writing it create-only when absent; refuses a mismatched object. */
98
+ export async function ensurePoolObject(bucket: R2BucketLike, key: string, bytes: Uint8Array, mediaType: string): Promise<void> {
99
+ const address = POOL_ENTRY_RE.exec(key.replace(/^assets\//, ""))?.[1];
100
+ if (!address) throw new R2StagingError("pool-key");
101
+ const existing = await readBounded(bucket, key, MAX_IMAGE);
102
+ if (existing && await sha256Hex(existing) !== address) throw new R2StagingError("store-mismatch");
103
+ if (!existing) {
104
+ if (!bucket.put) throw new R2StagingError("store-unwritable");
105
+ await bucket.put(key, bytes, {
106
+ httpMetadata: { contentType: mediaType, cacheControl: IMMUTABLE },
107
+ onlyIf: new Headers({ "If-None-Match": "*" }),
108
+ });
109
+ }
110
+ const readback = await readBounded(bucket, key, MAX_IMAGE);
111
+ if (!readback || readback.byteLength !== bytes.byteLength || await sha256Hex(readback) !== address) {
112
+ throw new R2StagingError("store-mismatch");
113
+ }
114
+ }
115
+
116
+ /** Create a retention hold create-only (an existing hold is left as it is) and prove it exists. */
117
+ export async function createHold(bucket: R2BucketLike, key: string, body: unknown): Promise<void> {
118
+ if (!bucket.put) throw new R2StagingError("store-unwritable");
119
+ await bucket.put(key, new TextEncoder().encode(JSON.stringify(body)), {
120
+ httpMetadata: { contentType: "application/json", cacheControl: "no-store" },
121
+ onlyIf: new Headers({ "If-None-Match": "*" }),
122
+ });
123
+ if (!(await bucket.get(key))) throw new R2StagingError("hold-missing");
124
+ }
125
+
126
+ function mediaTypeOf(entry: string): string {
127
+ const ext = POOL_ENTRY_RE.exec(entry)?.[2] ?? "";
128
+ const mediaType = PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION[ext];
129
+ if (!mediaType) throw new R2StagingError("media-type");
130
+ return mediaType;
131
+ }
132
+
133
+ /** The exact captured bytes for `<sha256><ext>` from the tenant's capture store. */
134
+ async function capturedBytes(captureBucket: R2BucketLike, tenant: string, entry: string): Promise<Uint8Array> {
135
+ const address = POOL_ENTRY_RE.exec(entry)?.[1];
136
+ if (!address) throw new R2StagingError("pool-key");
137
+ const bytes = await readBounded(captureBucket, `original-images/${tenant}/objects/${address}`, MAX_IMAGE);
138
+ if (!bytes || bytes.byteLength < 1 || await sha256Hex(bytes) !== address) throw new R2StagingError("original-missing");
139
+ return bytes;
140
+ }
141
+
142
+ /**
143
+ * Stage every captured original a version manifest records (`capturedOriginals`) into the public
144
+ * pool. The caller has already established the reference (the version manifest in this bucket) and
145
+ * waited at the fence.
146
+ */
147
+ export async function stageCapturedOriginalsR2(input: {
148
+ tenant: string;
149
+ entries: readonly string[];
150
+ captureBucket: R2BucketLike;
151
+ publicBucket: R2BucketLike;
152
+ }): Promise<number> {
153
+ let staged = 0;
154
+ for (const entry of input.entries) {
155
+ const key = `assets/${entry}`;
156
+ const present = await readBounded(input.publicBucket, key, MAX_IMAGE);
157
+ const address = POOL_ENTRY_RE.exec(entry)?.[1];
158
+ if (present && address && await sha256Hex(present) === address) continue;
159
+ await ensurePoolObject(input.publicBucket, key, await capturedBytes(input.captureBucket, input.tenant, entry), mediaTypeOf(entry));
160
+ staged++;
161
+ }
162
+ return staged;
163
+ }
164
+
165
+ export interface DeclaredDurableImage {
166
+ index: number;
167
+ name: string | null;
168
+ source: string;
169
+ locator: string;
170
+ }
171
+
172
+ /** Read-modify-write the status record; FAIL CLOSED on a record that exists but cannot be read. */
173
+ export async function recordDurableStatusKv(kv: StatusKvLike, tenant: string, update: {
174
+ revision: string;
175
+ at: string;
176
+ designated: Array<Omit<DurableAssetRecord, "designatedAt" | "designatedRevision" | "removalRequested">>;
177
+ declared: readonly string[];
178
+ }): Promise<void> {
179
+ const key = durableAssetsStatusKey(tenant);
180
+ const raw = await kv.get(key);
181
+ const parsed = parseDurableAssetsStatus(raw, tenant);
182
+ if (raw && !parsed) throw new R2StagingError("status-unreadable");
183
+ if (!parsed && update.designated.length === 0) return;
184
+ const next = mergeDurableDesignations(parsed ?? emptyDurableAssetsStatus(tenant), update);
185
+ await kv.put(key, JSON.stringify(next));
186
+ }
187
+
188
+ export type DurableDesignationResult =
189
+ | { ok: true; designated: string[] }
190
+ | { ok: false; reason: "durable-uncaptured"; unresolved: number[] };
191
+
192
+ /**
193
+ * Designate the declared durable images in R2 (ADR 0038): resolve each to its sealed
194
+ * `durable.<revision>` capture, create its hold, record the designation, wait at the fence, then
195
+ * stage and verify each object (re-asserting its hold) and record it verified in `r2`.
196
+ */
197
+ export async function designateDurableR2(input: {
198
+ tenant: string;
199
+ revision: string;
200
+ images: readonly DeclaredDurableImage[];
201
+ captureBucket: R2BucketLike;
202
+ publicBucket: R2BucketLike;
203
+ kv: StatusKvLike;
204
+ now?: () => Date;
205
+ awaitQuiescence?: QuiescenceWaiter;
206
+ }): Promise<DurableDesignationResult> {
207
+ if (input.images.length === 0) return { ok: true, designated: [] };
208
+ const revision = durableCaptureRevisionId(input.revision);
209
+ const manifestBytes = await readBounded(input.captureBucket, `original-images/${input.tenant}/revisions/${revision}/manifest.json`, MAX_MANIFEST);
210
+ const byLocator = new Map<string, { address: string; byteLength: number; mediaType: string }>();
211
+ if (manifestBytes) {
212
+ const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as {
213
+ version?: unknown; revisionId?: unknown;
214
+ entries?: Array<{ address?: unknown; byteLength?: unknown; mediaType?: unknown; provenance?: { locator?: unknown } }>;
215
+ };
216
+ if (manifest.version !== 1 || manifest.revisionId !== revision || !Array.isArray(manifest.entries)) {
217
+ throw new R2StagingError("manifest-invalid");
218
+ }
219
+ for (const entry of manifest.entries) {
220
+ if (typeof entry?.address !== "string" || !/^[0-9a-f]{64}$/.test(entry.address) ||
221
+ typeof entry.byteLength !== "number" || typeof entry.mediaType !== "string" ||
222
+ typeof entry.provenance?.locator !== "string") throw new R2StagingError("manifest-invalid");
223
+ byLocator.set(entry.provenance.locator, { address: entry.address, byteLength: entry.byteLength, mediaType: entry.mediaType });
224
+ }
225
+ }
226
+ const unresolved = input.images.filter((image) => !byLocator.has(image.locator)).map((image) => image.index);
227
+ if (unresolved.length > 0) return { ok: false, reason: "durable-uncaptured", unresolved };
228
+
229
+ const extByType = Object.fromEntries(Object.entries(PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION).map(([ext, type]) => [type, ext]));
230
+ const now = input.now ?? (() => new Date());
231
+ const at = now().toISOString();
232
+ const prepared = input.images.map((image) => {
233
+ const captured = byLocator.get(image.locator)!;
234
+ const ext = extByType[captured.mediaType];
235
+ if (!ext) throw new R2StagingError("media-type");
236
+ const entry = `${captured.address}${ext}`;
237
+ return { image, entry, mediaType: captured.mediaType, holdKey: durableHoldKey(input.tenant, entry),
238
+ holdBody: { version: 1, class: "durable", tenant: input.tenant, object: `assets/${entry}`, designatedRevision: input.revision, designatedAt: at } };
239
+ });
240
+ for (const item of prepared) await createHold(input.publicBucket, item.holdKey, item.holdBody);
241
+ const declared = prepared.map((item) => item.entry);
242
+ await recordDurableStatusKv(input.kv, input.tenant, { revision: input.revision, at, declared,
243
+ designated: prepared.map((item) => ({ entry: item.entry, name: item.image.name, source: item.image.source, verifiedStores: [] })) });
244
+ await awaitR2Quiescence(input.publicBucket, input.awaitQuiescence);
245
+ for (const item of prepared) {
246
+ await createHold(input.publicBucket, item.holdKey, item.holdBody);
247
+ await ensurePoolObject(input.publicBucket, `assets/${item.entry}`,
248
+ await capturedBytes(input.captureBucket, input.tenant, item.entry), item.mediaType);
249
+ }
250
+ await recordDurableStatusKv(input.kv, input.tenant, { revision: input.revision, at: now().toISOString(), declared,
251
+ designated: prepared.map((item) => ({ entry: item.entry, name: item.image.name, source: item.image.source, verifiedStores: ["r2"] })) });
252
+ return { ok: true, designated: declared };
253
+ }
254
+
255
+ /**
256
+ * The `worker-r2-pointer` go-live/rollback step that runs BEFORE the pointer moves: the target
257
+ * version's manifest (already staged in R2) is the reference; once no sweep is in flight, every
258
+ * captured original it records is staged into the public pool, and its declared durable images are
259
+ * designated. Without this, a pointer-served page would 404 its captured images (CI never stages
260
+ * captured originals into R2 for this provider).
261
+ */
262
+ export function makeWorkerR2PinnedOriginalStager(deps: {
263
+ publicBucket: R2BucketLike;
264
+ captureBucket: R2BucketLike;
265
+ kv: StatusKvLike;
266
+ now?: () => Date;
267
+ awaitQuiescence?: QuiescenceWaiter;
268
+ }) {
269
+ return async ({ tenantId, treeDigest }: { tenantId: string; versionId: string; treeDigest?: string | null }): Promise<void> => {
270
+ if (!treeDigest) return;
271
+ const bytes = await readBounded(deps.publicBucket, `versions/${treeDigest}/manifest.json`, 8 * MAX_MANIFEST);
272
+ if (!bytes) throw new R2StagingError("version-manifest-missing");
273
+ const manifest = JSON.parse(new TextDecoder().decode(bytes)) as {
274
+ tenant?: unknown; sourceSha?: unknown; capturedOriginals?: unknown;
275
+ durableAssets?: { images?: DeclaredDurableImage[] };
276
+ };
277
+ if (manifest.tenant !== tenantId) throw new R2StagingError("version-tenant-mismatch");
278
+ const entries = manifest.capturedOriginals ?? [];
279
+ if (!Array.isArray(entries) || entries.some((entry) => typeof entry !== "string" || !POOL_ENTRY_RE.test(entry))) {
280
+ throw new R2StagingError("manifest-invalid");
281
+ }
282
+ await awaitR2Quiescence(deps.publicBucket, deps.awaitQuiescence);
283
+ await stageCapturedOriginalsR2({ tenant: tenantId, entries: entries as string[],
284
+ captureBucket: deps.captureBucket, publicBucket: deps.publicBucket });
285
+ const images = manifest.durableAssets?.images ?? [];
286
+ if (images.length > 0) {
287
+ if (typeof manifest.sourceSha !== "string") throw new R2StagingError("manifest-invalid");
288
+ const result = await designateDurableR2({ tenant: tenantId, revision: manifest.sourceSha, images,
289
+ captureBucket: deps.captureBucket, publicBucket: deps.publicBucket, kv: deps.kv,
290
+ ...(deps.now ? { now: deps.now } : {}), ...(deps.awaitQuiescence ? { awaitQuiescence: deps.awaitQuiescence } : {}) });
291
+ if (!result.ok) throw new R2StagingError(result.reason);
292
+ }
293
+ };
294
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The runtime bindings for `makeWorkerR2PinnedOriginalStager`: the public pool (STATIC_BUNDLES), the
3
+ * capture store (PRODUCT_IMAGES, else TENANT_ASSETS — the same resolution hosted capture writes
4
+ * with) and TENANT_CACHE for the durable-image status record. Unbound ⇒ every pre-flip stage
5
+ * refuses, so a pointer-served version can never go live missing its captured images.
6
+ */
7
+ import { readKv, readR2 } from "@/lib/env";
8
+ import { makeWorkerR2PinnedOriginalStager, R2StagingError, type R2BucketLike, type StatusKvLike } from "./durableR2.js";
9
+
10
+ export async function workerR2PinnedOriginalStagerFromEnv(): Promise<
11
+ (args: { tenantId: string; versionId: string; treeDigest?: string | null }) => Promise<void>
12
+ > {
13
+ const [publicBucket, productImages, tenantAssets, kv] = await Promise.all([
14
+ readR2("STATIC_BUNDLES"), readR2("PRODUCT_IMAGES"), readR2("TENANT_ASSETS"), readKv("TENANT_CACHE"),
15
+ ]);
16
+ // No capture store bound: a version that records captured originals or durable images refuses
17
+ // when the stage reads one; a version with none needs no capture store at all.
18
+ const captureBucket = productImages ?? tenantAssets ?? {
19
+ async get() { throw new R2StagingError("capture-store-unbound"); },
20
+ };
21
+ if (!publicBucket || !kv) {
22
+ return async ({ treeDigest }) => {
23
+ if (treeDigest) throw new R2StagingError("bindings-unavailable");
24
+ };
25
+ }
26
+ return makeWorkerR2PinnedOriginalStager({
27
+ publicBucket: publicBucket as unknown as R2BucketLike,
28
+ captureBucket: captureBucket as unknown as R2BucketLike,
29
+ kv: kv as unknown as StatusKvLike,
30
+ });
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.2.24",
3
+ "version": "2.2.26",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -16,6 +16,8 @@
16
16
  "./image-finding-acceptance": "./src/image-finding-acceptance.ts",
17
17
  "./image-preview-rewrite-overlay": "./src/image-preview-rewrite-overlay.ts",
18
18
  "./hosted-image-media-types": "./src/hosted-image-media-types.ts",
19
+ "./durable-assets": "./src/durable-assets.ts",
20
+ "./retention-holds": "./src/retention-holds.ts",
19
21
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
20
22
  "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
21
23
  "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs"
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Durable public images (ADR 0038): the tenant's declaration, and the names every plane shares.
3
+ *
4
+ * A tenant lists images in `content/durable-assets.json` that must stay publicly reachable at their
5
+ * content-addressed `/_a/<sha256><ext>` URL until an owner explicitly removes them. Capture seals
6
+ * them under `durable.<revision>`; publish stages each into the pool beside a create-only retention
7
+ * hold that both pool GCs honour. Pure: no I/O, safe on the Worker and in Node.
8
+ */
9
+
10
+ export const DURABLE_ASSETS_PATH = "content/durable-assets.json";
11
+ export const DURABLE_ASSETS_MAX_IMAGES = 200;
12
+ const MAX_DECLARATION_BYTES = 64 * 1024;
13
+ const MAX_NAME = 120;
14
+ const LOCAL_RE = /^(?:public|content)\/(?:[A-Za-z0-9._@~-]+\/)*[A-Za-z0-9._@~-]+\.(?:png|jpe?g|gif|webp|avif)$/i;
15
+ const DNS_NAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
16
+ const CONTROL = /[\u0000-\u001f\u007f]/;
17
+ const POOL_ENTRY_RE = /^[0-9a-f]{64}(?:\.[A-Za-z0-9]+)?$/;
18
+
19
+ export type DurableImageSource =
20
+ | { kind: "local"; path: string }
21
+ | { kind: "remote"; url: string };
22
+
23
+ export interface DurableImageDeclaration {
24
+ /** Position in the declaration (0-based); the only handle reported back to operators. */
25
+ index: number;
26
+ name: string | null;
27
+ source: DurableImageSource;
28
+ /** The provenance locator a capture manifest records for this source. */
29
+ locator: string;
30
+ }
31
+
32
+ export type DurableDeclarationParse =
33
+ | { ok: true; images: DurableImageDeclaration[] }
34
+ | { ok: false; error: string };
35
+
36
+ /** The capture revision durable images seal under — separate from the page manifest. */
37
+ export function durableCaptureRevisionId(revisionId: string): string {
38
+ return `durable.${revisionId.replace(/^sha256:/, "sha256-")}`;
39
+ }
40
+
41
+ /** The retention hold that keeps the pool object `assets/<poolEntry>` (`<sha256><ext>`). */
42
+ export function durableHoldKey(tenant: string, poolEntry: string): string {
43
+ if (!POOL_ENTRY_RE.test(poolEntry)) throw new Error("durable hold needs a <sha256><ext> pool entry");
44
+ return `retention/durable/${tenant}/${poolEntry}`;
45
+ }
46
+
47
+ /** KV key of the tenant's durable-image status record (status, removals with actor ids). */
48
+ export function durableAssetsStatusKey(tenant: string): string {
49
+ return `durable-assets:${tenant}`;
50
+ }
51
+
52
+ function remoteLocator(url: URL): string {
53
+ return `${url.origin}${url.pathname}`;
54
+ }
55
+
56
+ function parseSource(raw: unknown): DurableImageSource | null {
57
+ if (typeof raw !== "string" || !raw || CONTROL.test(raw)) return null;
58
+ if (/^https:\/\//i.test(raw)) {
59
+ if (raw.length > 2048) return null;
60
+ let url: URL;
61
+ try {
62
+ url = new URL(raw);
63
+ } catch {
64
+ return null;
65
+ }
66
+ if (url.protocol !== "https:" || url.username || url.password || url.port || url.search || url.hash ||
67
+ raw.includes("?") || raw.includes("#") || !DNS_NAME.test(url.hostname) || url.pathname === "/") return null;
68
+ return { kind: "remote", url: url.href };
69
+ }
70
+ if (raw.length > 512 || raw.includes("\\") || raw.split("/").some((part) => part === "." || part === "..")) return null;
71
+ return LOCAL_RE.test(raw) ? { kind: "local", path: raw } : null;
72
+ }
73
+
74
+ /** The provenance locator a capture manifest records for `source` (no leading slash for local). */
75
+ export function durableSourceLocator(source: DurableImageSource): string {
76
+ return source.kind === "local" ? source.path : remoteLocator(new URL(source.url));
77
+ }
78
+
79
+ /**
80
+ * Parse `content/durable-assets.json`. Strict: an unknown key, a malformed or duplicate source,
81
+ * or more than {@link DURABLE_ASSETS_MAX_IMAGES} images refuses the whole declaration, so a typo
82
+ * can never silently leave an image undesignated. Errors name positions, never values.
83
+ */
84
+ export function parseDurableAssetsDeclaration(text: string): DurableDeclarationParse {
85
+ if (text.length > MAX_DECLARATION_BYTES) return { ok: false, error: "declaration is too large" };
86
+ let parsed: unknown;
87
+ try {
88
+ parsed = JSON.parse(text);
89
+ } catch {
90
+ return { ok: false, error: "declaration is not valid JSON" };
91
+ }
92
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ok: false, error: "declaration must be an object" };
93
+ const root = parsed as Record<string, unknown>;
94
+ if (Object.keys(root).some((key) => key !== "version" && key !== "images")) {
95
+ return { ok: false, error: "declaration has an unknown top-level key" };
96
+ }
97
+ if (root.version !== 1) return { ok: false, error: "declaration version must be 1" };
98
+ if (!Array.isArray(root.images)) return { ok: false, error: "declaration images must be an array" };
99
+ if (root.images.length > DURABLE_ASSETS_MAX_IMAGES) {
100
+ return { ok: false, error: `declaration lists more than ${DURABLE_ASSETS_MAX_IMAGES} images` };
101
+ }
102
+ const images: DurableImageDeclaration[] = [];
103
+ const seen = new Set<string>();
104
+ for (const [index, raw] of root.images.entries()) {
105
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { ok: false, error: `image ${index + 1} must be an object` };
106
+ const entry = raw as Record<string, unknown>;
107
+ if (Object.keys(entry).some((key) => key !== "source" && key !== "name")) {
108
+ return { ok: false, error: `image ${index + 1} has an unknown key` };
109
+ }
110
+ const source = parseSource(entry.source);
111
+ if (!source) return { ok: false, error: `image ${index + 1} has an unsupported source` };
112
+ if (entry.name !== undefined && (typeof entry.name !== "string" || !entry.name.trim() ||
113
+ entry.name.length > MAX_NAME || CONTROL.test(entry.name))) {
114
+ return { ok: false, error: `image ${index + 1} has an invalid name` };
115
+ }
116
+ const locator = durableSourceLocator(source);
117
+ if (seen.has(locator)) return { ok: false, error: `image ${index + 1} repeats an earlier source` };
118
+ seen.add(locator);
119
+ images.push({ index, name: typeof entry.name === "string" ? entry.name.trim() : null, source, locator });
120
+ }
121
+ return { ok: true, images };
122
+ }
123
+
124
+ export type DurableImageStatus = "preview-only" | "release-managed" | "durable" | "removal-requested";
125
+
126
+ /** One designated image in the tenant's status record. */
127
+ export interface DurableAssetRecord {
128
+ /** `<sha256><ext>` — the pool entry and the tail of its `/_a/` URL. */
129
+ entry: string;
130
+ name: string | null;
131
+ /** The declared source, as written in the declaration. */
132
+ source: string;
133
+ designatedAt: string;
134
+ /** Tenant source revision whose publish first designated it. */
135
+ designatedRevision: string;
136
+ /** Stores the object and its hold were verified in (`s3`, `r2`). */
137
+ verifiedStores: Array<"s3" | "r2">;
138
+ /** True when the latest published declaration no longer lists it (removal may be requested). */
139
+ removalRequested: boolean;
140
+ }
141
+
142
+ export interface DurableAssetRemoval {
143
+ entry: string;
144
+ /** Opaque actor id of the owner who removed it — never an email or name. */
145
+ actorId: string;
146
+ at: string;
147
+ }
148
+
149
+ /** `durable-assets:<tenant>` in TENANT_CACHE. */
150
+ export interface DurableAssetsStatusRecord {
151
+ version: 1;
152
+ tenant: string;
153
+ updatedAt: string;
154
+ /** Revision of the latest publish that recorded this document. */
155
+ latestRevision: string | null;
156
+ entries: DurableAssetRecord[];
157
+ removals: DurableAssetRemoval[];
158
+ }
159
+
160
+ export function emptyDurableAssetsStatus(tenant: string): DurableAssetsStatusRecord {
161
+ return { version: 1, tenant, updatedAt: new Date(0).toISOString(), latestRevision: null, entries: [], removals: [] };
162
+ }
163
+
164
+ /** Parse a stored status record; null when absent or malformed (callers treat malformed as unknown). */
165
+ export function parseDurableAssetsStatus(raw: string | null, tenant: string): DurableAssetsStatusRecord | null {
166
+ if (!raw) return null;
167
+ try {
168
+ const value = JSON.parse(raw) as Partial<DurableAssetsStatusRecord>;
169
+ if (value.version !== 1 || value.tenant !== tenant || !Array.isArray(value.entries) || !Array.isArray(value.removals)) return null;
170
+ if (value.entries.some((entry) => !entry || !POOL_ENTRY_RE.test(String(entry.entry)))) return null;
171
+ if (value.removals.some((removal) => !removal || !POOL_ENTRY_RE.test(String(removal.entry)) ||
172
+ typeof removal.actorId !== "string" || !removal.actorId || typeof removal.at !== "string")) return null;
173
+ return value as DurableAssetsStatusRecord;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Merge one publisher's designations into the status record. Monotonic: an entry is never dropped
181
+ * here — one this publish's declaration no longer lists is marked `removalRequested`, and only the
182
+ * owner removal operation takes it out. `verifiedStores` accumulate across publishers (each store's
183
+ * publisher verifies its own), and a store is only ever added by a verification.
184
+ * `declared` is the publish's full declaration (by entry); null when this publisher did not read
185
+ * the declaration (it then never marks anything removal-requested).
186
+ */
187
+ export function mergeDurableDesignations(
188
+ current: DurableAssetsStatusRecord,
189
+ publish: {
190
+ revision: string;
191
+ at: string;
192
+ designated: Array<Omit<DurableAssetRecord, "designatedAt" | "designatedRevision" | "removalRequested">>;
193
+ declared?: readonly string[] | null;
194
+ },
195
+ ): DurableAssetsStatusRecord {
196
+ const byEntry = new Map(current.entries.map((entry) => [entry.entry, entry]));
197
+ for (const item of publish.designated) {
198
+ const previous = byEntry.get(item.entry);
199
+ byEntry.set(item.entry, {
200
+ ...item,
201
+ verifiedStores: [...new Set([...(previous?.verifiedStores ?? []), ...item.verifiedStores])].sort() as Array<"s3" | "r2">,
202
+ designatedAt: previous?.designatedAt ?? publish.at,
203
+ designatedRevision: previous?.designatedRevision ?? publish.revision,
204
+ removalRequested: false,
205
+ });
206
+ }
207
+ const declared = publish.declared === undefined
208
+ ? new Set(publish.designated.map((item) => item.entry))
209
+ : publish.declared === null ? null : new Set(publish.declared);
210
+ if (declared) {
211
+ for (const [entry, record] of byEntry) {
212
+ if (!declared.has(entry)) byEntry.set(entry, { ...record, removalRequested: true });
213
+ }
214
+ }
215
+ return {
216
+ ...current,
217
+ updatedAt: publish.at,
218
+ latestRevision: publish.revision,
219
+ entries: [...byEntry.values()].sort((a, b) => (a.entry < b.entry ? -1 : a.entry > b.entry ? 1 : 0)),
220
+ };
221
+ }
222
+
223
+ export type DurableRemovalDecision =
224
+ | { ok: true; record: DurableAssetsStatusRecord }
225
+ | { ok: false; reason: "unknown-entry" | "still-declared" | "invalid-actor" };
226
+
227
+ /**
228
+ * The owner removal operation (ADR 0038 §5), as a pure step: refuse an entry the record does not
229
+ * hold or that the latest published declaration still lists; otherwise drop it from `entries` and
230
+ * append `{entry, actorId, at}` to `removals`. Both pool GCs release the hold once the removal is
231
+ * older than the normal grace; the object is then ordinary.
232
+ */
233
+ export function removeDurableEntry(
234
+ record: DurableAssetsStatusRecord,
235
+ removal: { entry: string; actorId: string; at: string },
236
+ ): DurableRemovalDecision {
237
+ if (!/^[A-Za-z0-9_:.-]{1,128}$/.test(removal.actorId) || removal.actorId.includes("@")) {
238
+ return { ok: false, reason: "invalid-actor" };
239
+ }
240
+ const current = record.entries.find((entry) => entry.entry === removal.entry);
241
+ if (!current) return { ok: false, reason: "unknown-entry" };
242
+ if (!current.removalRequested) return { ok: false, reason: "still-declared" };
243
+ return {
244
+ ok: true,
245
+ record: {
246
+ ...record,
247
+ updatedAt: removal.at,
248
+ entries: record.entries.filter((entry) => entry.entry !== removal.entry),
249
+ removals: [...record.removals, { entry: removal.entry, actorId: removal.actorId, at: removal.at }],
250
+ },
251
+ };
252
+ }
253
+
254
+ export interface DurableImageStatusView {
255
+ entry: string;
256
+ name: string | null;
257
+ source: string;
258
+ status: DurableImageStatus;
259
+ /** Issued only once verified in every store the tenant's declared provider serves from. */
260
+ publicUrl: string | null;
261
+ designatedAt: string;
262
+ verifiedStores: Array<"s3" | "r2">;
263
+ }
264
+
265
+ /**
266
+ * The owner-facing status of every designated image: `durable` (with its public URL) once verified
267
+ * in every store `serving.provider` uses; `removal-requested` when the latest declaration dropped it
268
+ * (still held until an owner removes it); otherwise `release-managed` — published, not yet verified
269
+ * as durable, so only retained releases keep it.
270
+ */
271
+ export function durableStatusView(
272
+ record: DurableAssetsStatusRecord,
273
+ { provider, publicOrigin }: { provider: string | null | undefined; publicOrigin: string | null },
274
+ ): DurableImageStatusView[] {
275
+ const required = durableRequiredStores(provider);
276
+ return record.entries.map((entry) => {
277
+ const verified = required !== null && required.every((store) => entry.verifiedStores.includes(store));
278
+ const status: DurableImageStatus = entry.removalRequested ? "removal-requested" : verified ? "durable" : "release-managed";
279
+ return {
280
+ entry: entry.entry,
281
+ name: entry.name,
282
+ source: entry.source,
283
+ status,
284
+ publicUrl: verified && publicOrigin ? `${publicOrigin.replace(/\/+$/, "")}/_a/${entry.entry}` : null,
285
+ designatedAt: entry.designatedAt,
286
+ verifiedStores: entry.verifiedStores,
287
+ };
288
+ });
289
+ }
290
+
291
+ /** Required stores for a declared serving provider; null when the provider cannot verify durable images. */
292
+ export function durableRequiredStores(provider: string | null | undefined): Array<"s3" | "r2"> | null {
293
+ if (provider === "s3-overwrite") return ["s3"];
294
+ if (provider === "worker-r2-pointer") return ["r2"];
295
+ return null;
296
+ }
@@ -1428,9 +1428,12 @@ function collectDeclaredCustomProperties(artifacts: readonly ImageDependencyArti
1428
1428
 
1429
1429
  /** Scan a revision's already-read artifacts with no network or filesystem I/O. */
1430
1430
  export function scanImageDependencies(
1431
- artifacts: readonly ImageDependencyArtifact[],
1431
+ scanned: readonly ImageDependencyArtifact[],
1432
1432
  options: ImageDependencyScanOptions,
1433
1433
  ): ImageDependencyScan {
1434
+ // The durable-image declaration (ADR 0038) lists images to keep, not images a page renders; its
1435
+ // sources are captured by the durable pass. Scanning it would turn each into a page finding.
1436
+ const artifacts = scanned.filter((artifact) => !isDurableAssetsDeclarationPath(artifact.path));
1434
1437
  const dependencies: ImageDependency[] = [];
1435
1438
  const platformHosted = preparePlatformHostedConfig(options.platformHosted);
1436
1439
  const tenantOrigins = prepareTenantOrigins(options.tenantOrigins);
@@ -1548,3 +1551,8 @@ export function scanImageDependencies(
1548
1551
  })(),
1549
1552
  };
1550
1553
  }
1554
+
1555
+ /** `content/durable-assets.json`, however a caller spells the artifact path. */
1556
+ export function isDurableAssetsDeclarationPath(path: string): boolean {
1557
+ return path.replace(/^\/+/, "") === "content/durable-assets.json";
1558
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Retention holds and the pool-GC lease (ADR 0038) — ONE implementation for both stores.
3
+ *
4
+ * The S3 pool GC (`scripts/publish/gc-asset-pool.mjs`) and the R2 bundle GC
5
+ * (`apps/storefront/src/lib/publish/bundleGc.ts`) both import these rules, so the two CDNs cannot
6
+ * drift on what a hold protects, when it is released, or how a sweep and a publish order their steps.
7
+ *
8
+ * Hold keys (objects in the same bucket as the pool, `assets/<sha256><ext>`):
9
+ * retention/durable/<tenant>/<sha256><ext> durable image (owner-removed)
10
+ * retention/publication-space/<tenant>/<space>/<version>/<sha256><ext> a publication-space version's
11
+ * reference (release-managed)
12
+ * Any other well-formed `retention/<class>/…/<sha256><ext>` key is still a reference (fail closed):
13
+ * it is never released by these rules and never deleted.
14
+ *
15
+ * Lease protocol (a publish or designation never loses to a concurrent sweep):
16
+ * GC 1. write the lease (expiry = now + TTL) 2. read every reference source
17
+ * 3. delete only while `leaseAllowsDelete` 4. remove the lease
18
+ * publisher 1. write its references (version manifest, holds)
19
+ * 2. wait while a lease is live (`awaitGcQuiescence`)
20
+ * 3. verify every referenced object, re-creating any that is missing
21
+ * A sweep that starts after step 1 sees the references; one already running finishes (or can no
22
+ * longer delete) before the publisher's verification, which is therefore the last word.
23
+ */
24
+ import type { DurableAssetsStatusRecord } from "./durable-assets.js";
25
+ import type { PublicationVersionStateV1 } from "./publication-version.js";
26
+
27
+ export const GC_LEASE_KEY = "retention/gc-lease.json";
28
+ export const GC_LEASE_TTL_MS = 10 * 60 * 1000;
29
+ /** Clock skew absorbed on both sides (the GC and the publisher run on different machines). */
30
+ export const GC_LEASE_SKEW_MS = 60 * 1000;
31
+ export const GC_LEASE_MAX_WAIT_MS = GC_LEASE_TTL_MS + 2 * GC_LEASE_SKEW_MS;
32
+ /** The grace an unreferenced object gets before collection (matches both GCs' asset grace). */
33
+ export const RELEASE_GRACE_MS = 24 * 60 * 60 * 1000;
34
+
35
+ const SEG = "[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?";
36
+ const ENTRY = "([0-9a-f]{64})(\\.[A-Za-z0-9]+)?";
37
+ const DURABLE_RE = new RegExp(`^retention/durable/(${SEG})/${ENTRY}$`, "i");
38
+ const SPACE_RE = new RegExp(`^retention/publication-space/(${SEG})/([A-Za-z0-9._-]{1,128})/([A-Za-z0-9._-]{1,128})/${ENTRY}$`, "i");
39
+ const ANY_HOLD_RE = new RegExp(`^retention/[a-z][a-z-]*/.+/${ENTRY}$`, "i");
40
+
41
+ export type ParsedRetentionKey =
42
+ | { kind: "durable"; tenant: string; hash: string; entry: string }
43
+ | { kind: "publication-space"; tenant: string; spaceId: string; versionId: string; hash: string; entry: string }
44
+ | { kind: "other-hold"; hash: string; entry: string }
45
+ | { kind: "lease" }
46
+ | { kind: "unrecognized" };
47
+
48
+ export function parseRetentionKey(key: string): ParsedRetentionKey {
49
+ if (key === GC_LEASE_KEY) return { kind: "lease" };
50
+ let m = DURABLE_RE.exec(key);
51
+ if (m) return { kind: "durable", tenant: m[1]!, hash: m[2]!.toLowerCase(), entry: `${m[2]!.toLowerCase()}${m[3] ?? ""}` };
52
+ m = SPACE_RE.exec(key);
53
+ if (m) {
54
+ return { kind: "publication-space", tenant: m[1]!, spaceId: m[2]!, versionId: m[3]!,
55
+ hash: m[4]!.toLowerCase(), entry: `${m[4]!.toLowerCase()}${m[5] ?? ""}` };
56
+ }
57
+ m = ANY_HOLD_RE.exec(key);
58
+ if (m) return { kind: "other-hold", hash: m[1]!.toLowerCase(), entry: `${m[1]!.toLowerCase()}${m[2] ?? ""}` };
59
+ return { kind: "unrecognized" };
60
+ }
61
+
62
+ export function publicationSpaceHoldKey(tenant: string, spaceId: string, versionId: string, poolEntry: string): string {
63
+ return `retention/publication-space/${tenant}/${spaceId}/${versionId}/${poolEntry}`;
64
+ }
65
+
66
+ export interface HoldObject {
67
+ key: string;
68
+ /** Epoch ms the hold object was written; null when unknown (never released). */
69
+ createdAt: number | null;
70
+ }
71
+
72
+ export interface HoldPlanInput {
73
+ holds: readonly HoldObject[];
74
+ /** `durable-assets:<tenant>` per tenant; null = absent or unreadable (nothing released). */
75
+ durableRecords: ReadonlyMap<string, DurableAssetsStatusRecord | null>;
76
+ /** `cust:publication-state:<tenant>` per tenant; null = absent or unreadable (nothing released). */
77
+ spaceStates: ReadonlyMap<string, PublicationVersionStateV1 | null>;
78
+ now: number;
79
+ graceMs?: number;
80
+ keep?: number;
81
+ graceDays?: number;
82
+ }
83
+
84
+ export interface HoldPlan {
85
+ /** Pool hashes every kept hold references. */
86
+ referencedHashes: Set<string>;
87
+ /** Hold keys to delete this run. */
88
+ releaseKeys: string[];
89
+ /** Why each released hold goes, for the run log. */
90
+ reasons: Record<string, string>;
91
+ }
92
+
93
+ /**
94
+ * The publication-space versions still retained for `tenant`, as `<space>/<version>`: per space, the
95
+ * newest `keep` by creation, every version created within `graceDays` plus the newest one before the
96
+ * cutoff, every version a channel pointer names, and every transition's previous version (a
97
+ * rollback target). The same rule the whole-site planes apply to their versions.
98
+ */
99
+ export function retainedPublicationVersions(
100
+ state: PublicationVersionStateV1,
101
+ { now, keep = 30, graceDays = 7 }: { now: number; keep?: number; graceDays?: number },
102
+ ): Set<string> {
103
+ const retained = new Set<string>();
104
+ const bySpace = new Map<string, Array<{ versionId: string; created: number }>>();
105
+ for (const version of state.versions) {
106
+ const list = bySpace.get(version.spaceId) ?? bySpace.set(version.spaceId, []).get(version.spaceId)!;
107
+ list.push({ versionId: version.versionId, created: Date.parse(version.createdAt) || 0 });
108
+ }
109
+ const cutoff = now - graceDays * 24 * 60 * 60 * 1000;
110
+ for (const [spaceId, list] of bySpace) {
111
+ list.sort((a, b) => b.created - a.created || (a.versionId < b.versionId ? 1 : -1));
112
+ for (const v of list.slice(0, Math.max(1, keep))) retained.add(`${spaceId}/${v.versionId}`);
113
+ for (const v of list) {
114
+ retained.add(`${spaceId}/${v.versionId}`);
115
+ if (!(v.created >= cutoff)) break;
116
+ }
117
+ }
118
+ for (const pointer of state.pointers) retained.add(`${pointer.spaceId}/${pointer.versionId}`);
119
+ for (const transition of state.transitions) {
120
+ retained.add(`${transition.spaceId}/${transition.next.versionId}`);
121
+ if (transition.previous) retained.add(`${transition.spaceId}/${transition.previous.versionId}`);
122
+ }
123
+ return retained;
124
+ }
125
+
126
+ /**
127
+ * Decide which holds stay (their hashes are references) and which are released.
128
+ *
129
+ * - durable: released only for a recorded owner removal of that entry that is older than the grace,
130
+ * when the hold was created before the removal and the entry was not designated again after it.
131
+ * - publication-space: released when its version is no longer retained by the tenant's space state
132
+ * and the hold is older than the grace. An absent or unreadable state releases nothing.
133
+ * - any other hold: never released.
134
+ */
135
+ export function planRetentionHolds(input: HoldPlanInput): HoldPlan {
136
+ const graceMs = input.graceMs ?? RELEASE_GRACE_MS;
137
+ const referencedHashes = new Set<string>();
138
+ const releaseKeys: string[] = [];
139
+ const reasons: Record<string, string> = {};
140
+ const retainedByTenant = new Map<string, Set<string> | null>();
141
+ for (const hold of input.holds) {
142
+ const parsed = parseRetentionKey(hold.key);
143
+ if (parsed.kind === "lease" || parsed.kind === "unrecognized") continue;
144
+ let release = false;
145
+ if (parsed.kind === "durable") {
146
+ const record = input.durableRecords.get(parsed.tenant) ?? null;
147
+ const removedAt = latest(record?.removals.filter((r) => r.entry === parsed.entry).map((r) => r.at));
148
+ const designatedAt = latest(record?.entries.filter((e) => e.entry === parsed.entry).map((e) => e.designatedAt));
149
+ release = removedAt !== null && input.now >= removedAt + graceMs &&
150
+ (designatedAt === null || designatedAt <= removedAt) &&
151
+ hold.createdAt !== null && hold.createdAt < removedAt;
152
+ if (release) reasons[hold.key] = "durable hold released by a recorded owner removal";
153
+ } else if (parsed.kind === "publication-space") {
154
+ if (!retainedByTenant.has(parsed.tenant)) {
155
+ const state = input.spaceStates.get(parsed.tenant) ?? null;
156
+ retainedByTenant.set(parsed.tenant, state
157
+ ? retainedPublicationVersions(state, { now: input.now, keep: input.keep, graceDays: input.graceDays })
158
+ : null);
159
+ }
160
+ const retained = retainedByTenant.get(parsed.tenant);
161
+ release = retained != null && !retained.has(`${parsed.spaceId}/${parsed.versionId}`) &&
162
+ hold.createdAt !== null && input.now >= hold.createdAt + graceMs;
163
+ if (release) reasons[hold.key] = "publication-space version no longer retained";
164
+ }
165
+ if (release) releaseKeys.push(hold.key);
166
+ else referencedHashes.add(parsed.hash);
167
+ }
168
+ return { referencedHashes, releaseKeys: releaseKeys.sort(), reasons };
169
+ }
170
+
171
+ function latest(values: readonly string[] | undefined): number | null {
172
+ let best: number | null = null;
173
+ for (const value of values ?? []) {
174
+ const at = Date.parse(value);
175
+ if (Number.isFinite(at) && (best === null || at > best)) best = at;
176
+ }
177
+ return best;
178
+ }
179
+
180
+ export function newGcLease(now: number, ttlMs = GC_LEASE_TTL_MS): { version: 1; startedAt: string; expiresAt: string } {
181
+ return { version: 1, startedAt: new Date(now).toISOString(), expiresAt: new Date(now + ttlMs).toISOString() };
182
+ }
183
+
184
+ /** The lease's expiry, null for no lease. An unparseable lease is live for one TTL (fail closed). */
185
+ export function gcLeaseExpiry(raw: Uint8Array | string | null, now: number): number | null {
186
+ if (raw == null) return null;
187
+ try {
188
+ const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
189
+ const value = JSON.parse(text) as { version?: unknown; expiresAt?: unknown };
190
+ const expires = Date.parse(String(value?.expiresAt));
191
+ if (value?.version === 1 && Number.isFinite(expires)) return expires;
192
+ } catch {
193
+ // unparseable: live
194
+ }
195
+ return now + GC_LEASE_TTL_MS;
196
+ }
197
+
198
+ /** GC side: may this sweep still delete? */
199
+ export function leaseAllowsDelete(expiresAt: number, now: number): boolean {
200
+ return now < expiresAt - GC_LEASE_SKEW_MS;
201
+ }
202
+
203
+ /**
204
+ * Publisher side: wait while a sweep's lease is live; resolves once none is. Throws when one stays
205
+ * live past `maxWaitMs` — the caller refuses rather than guess.
206
+ */
207
+ export async function awaitGcQuiescence({
208
+ readLease,
209
+ now = Date.now,
210
+ sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
211
+ maxWaitMs = GC_LEASE_MAX_WAIT_MS,
212
+ }: {
213
+ readLease(): Promise<Uint8Array | string | null>;
214
+ now?: () => number;
215
+ sleep?: (ms: number) => Promise<void>;
216
+ maxWaitMs?: number;
217
+ }): Promise<{ waited: boolean }> {
218
+ const started = now();
219
+ let waited = false;
220
+ for (;;) {
221
+ const expiry = gcLeaseExpiry(await readLease(), now());
222
+ if (expiry == null || now() >= expiry + GC_LEASE_SKEW_MS) return { waited };
223
+ if (now() - started >= maxWaitMs) throw new Error("a pool GC sweep held its lease past the publisher's wait");
224
+ waited = true;
225
+ await sleep(Math.min(5000, Math.max(250, expiry + GC_LEASE_SKEW_MS - now())));
226
+ }
227
+ }