@tokenoftrust/storefront-runner 4.11.16 → 4.12.1

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.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Tenant binaries never live in the tenant repo. The platform's content-addressed `/_a/<sha256>`
3
- * pool owns them and the store's repo holds references only. Platform capture of catalog images
4
- * is pending: until it owns them, an image stays at its source URL, go-live requires 0 uncaptured
5
- * catalog images, and the source store is never shut down before that check passes. This decides,
3
+ * pool owns them and the store's repo holds references only. Publication captures source images;
4
+ * go-live requires zero uncaptured images in every type and sampled live owned-image responses
5
+ * before the source store is shut down. This decides,
6
6
  * from a listing of the store's `public/` tree, whether the repo is carrying binaries it must not.
7
7
  *
8
8
  * Findings, each naming its remedy:
@@ -28,6 +28,7 @@ import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/publication/shared
28
28
  import { SAMPLE_CART_HANDOFF_URL, commerceTenantId, compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 , storefrontModeRequiresSignIn } from "@tot/public-runtime";
29
29
  import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
30
30
  import { HTML_CACHE_POLICY_CAPTURE_PROBE } from "@tot/public-runtime";
31
+ import { resolveCapturedRequestVersion, resolveCustomizationEnv } from "@tot/public-runtime";
31
32
  import type { RenditionRegistryBucket } from "@/lib/assets/registeredRenditions";
32
33
  import { DEV_DRAFT_COOKIE } from "@/lib/developer-experience/dev/devDraft";
33
34
  import { applySecurityHeaders } from "@/lib/access/shared/securityHeaders";
@@ -317,7 +318,16 @@ async function serveRequest(
317
318
  previewTokenCookie: context.cookies.get("tot_preview")?.value,
318
319
  secret: await readEnv("CUSTOMIZATION_PREVIEW_SECRET"),
319
320
  });
320
- const capturedRevisionId = candidatePin?.versionId ?? cookieRevision;
321
+ const captureEnvironment = await resolveCustomizationEnv({
322
+ isPreviewHost: host.toLowerCase().startsWith("preview."), tenantId: tenant.tenant_id,
323
+ token: url.searchParams.get("__preview") ?? context.cookies.get("tot_preview")?.value,
324
+ secret: await readEnv("CUSTOMIZATION_PREVIEW_SECRET"), pathname: url.pathname,
325
+ nowSeconds: Math.floor(Date.now() / 1000),
326
+ });
327
+ const capturedRevisionId = renderPath.startsWith("/_a/") && !captureEnvironment.reject
328
+ ? await resolveCapturedRequestVersion({ get: (key) => kv?.get(key) ?? Promise.resolve(null), tenantId: tenant.tenant_id,
329
+ env: captureEnvironment.env, candidateVersionId: candidatePin?.versionId, cookieVersionId: cookieRevision })
330
+ : candidatePin?.versionId ?? cookieRevision;
321
331
  const originalsBucket =
322
332
  (capturedRevisionId || renderPath.startsWith("/_a/"))
323
333
  ? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
@@ -49,6 +49,7 @@ import {
49
49
  resolveCapabilities,
50
50
  capabilitiesArtifactPath,
51
51
  resolveServableChannelVersion,
52
+ resolveCapturedRequestVersion,
52
53
  resolveChannelVersion,
53
54
  serveTenantAsset,
54
55
  parseTenantAssetPath,
@@ -399,13 +400,10 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
399
400
  secret: previewSecret,
400
401
  nowSeconds,
401
402
  });
402
- // The shared preview sets no version cookie, yet its documents name `/_a/` captures of the
403
- // version the preview channel serves (capture overlay); those resolve against that version.
404
- const sharedPreviewRevision = !candidatePin && !cookieRevision && customization.env === "test" &&
405
- renderPath.startsWith("/_a/")
406
- ? (await resolveServableChannelVersion(kvGet, tenant.tenant_id, "preview")).versionId
407
- : null;
408
- const capturedRevisionId = candidatePin?.versionId ?? cookieRevision ?? sharedPreviewRevision;
403
+ const capturedRevisionId = renderPath.startsWith("/_a/")
404
+ ? await resolveCapturedRequestVersion({ get: kvGet, tenantId: tenant.tenant_id, env: customization.env,
405
+ candidateVersionId: candidatePin?.versionId, cookieVersionId: cookieRevision })
406
+ : candidatePin?.versionId ?? cookieRevision;
409
407
  const originalsBucket =
410
408
  (capturedRevisionId || renderPath.startsWith("/_a/"))
411
409
  ? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
@@ -39,10 +39,19 @@ export interface MatrixFault {
39
39
  mode: MatrixMode;
40
40
  /** `effect` mode: the effect after which the object is evicted. */
41
41
  effect?: string | null;
42
+ /** Test-only constructor delay after this eviction, to reproduce a late alarm. */
43
+ resumeDelayMs?: number;
42
44
  }
43
45
 
44
46
  /** What the object looked like at the instant a matrix fault evicted it. */
45
47
  export interface MatrixKill {
48
+ instanceId: string;
49
+ execution: number;
50
+ at: number;
51
+ stepStartSeq: number;
52
+ transitionSeq: number;
53
+ effectSeq: number;
54
+ resumeNotBefore: number;
46
55
  jobId: string;
47
56
  kind: string;
48
57
  step: string;
@@ -63,6 +72,10 @@ export interface MatrixKill {
63
72
  }
64
73
 
65
74
  export interface MatrixEffect {
75
+ seq: number;
76
+ at: number;
77
+ instanceId: string;
78
+ execution: number;
66
79
  jobId: string;
67
80
  kind: string;
68
81
  step: string;
@@ -102,6 +115,7 @@ export function previewServicesWithEffects(services: PreviewJobServices, landed:
102
115
  }
103
116
 
104
117
  interface Running {
118
+ execution: number;
105
119
  jobId: string;
106
120
  kind: string;
107
121
  step: string;
@@ -110,9 +124,9 @@ interface Running {
110
124
  function tables(state: DurableObjectState): void {
111
125
  const { sql } = state.storage;
112
126
  sql.exec(
113
- `CREATE TABLE IF NOT EXISTS matrix_faults (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, step TEXT, mode TEXT, effect TEXT, remaining INTEGER)`,
127
+ `CREATE TABLE IF NOT EXISTS matrix_faults (id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, step TEXT, mode TEXT, effect TEXT, remaining INTEGER, resume_delay INTEGER)`,
114
128
  );
115
- sql.exec(`CREATE TABLE IF NOT EXISTS matrix_effects (seq INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT, kind TEXT, step TEXT, effect TEXT)`);
129
+ sql.exec(`CREATE TABLE IF NOT EXISTS matrix_effects (seq INTEGER PRIMARY KEY AUTOINCREMENT, job_id TEXT, kind TEXT, step TEXT, effect TEXT, at INTEGER, instance_id TEXT, execution INTEGER)`);
116
130
  sql.exec(`CREATE TABLE IF NOT EXISTS matrix_execs (job_id TEXT, step TEXT, n INTEGER, PRIMARY KEY (job_id, step))`);
117
131
  sql.exec(`CREATE TABLE IF NOT EXISTS matrix_kills (seq INTEGER PRIMARY KEY AUTOINCREMENT, kill TEXT)`);
118
132
  sql.exec(`CREATE TABLE IF NOT EXISTS matrix_holds (job_id TEXT, step TEXT, released INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (job_id, step))`);
@@ -122,6 +136,7 @@ function tables(state: DurableObjectState): void {
122
136
  const HOLD_LIMIT_MS = 30_000;
123
137
 
124
138
  export class MatrixFaults {
139
+ private readonly instanceId = crypto.randomUUID();
125
140
  private running: Running | null = null;
126
141
 
127
142
  constructor(
@@ -144,7 +159,7 @@ export class MatrixFaults {
144
159
  ? {
145
160
  onTerminal: async (info: Parameters<NonNullable<JobKindDefinition<unknown>["onTerminal"]>>[0]) => {
146
161
  const previous = this.running;
147
- this.running = { jobId: info.jobId, kind: name, step: "on-terminal" };
162
+ this.running = { jobId: info.jobId, kind: name, step: "on-terminal", execution: 0 };
148
163
  try {
149
164
  await onTerminal(info);
150
165
  } finally {
@@ -171,7 +186,7 @@ export class MatrixFaults {
171
186
  step.name,
172
187
  )
173
188
  .one().n;
174
- this.running = { jobId: ctx.jobId, kind, step: step.name };
189
+ this.running = { jobId: ctx.jobId, kind, step: step.name, execution: n };
175
190
  try {
176
191
  if (n === 1) await this.maybeHold(kind, step.name, ctx);
177
192
  if (n === 1) await this.maybeKill("before", null);
@@ -190,22 +205,24 @@ export class MatrixFaults {
190
205
  readonly landed: Landed = async (effect) => {
191
206
  const running = this.running;
192
207
  this.state.storage.sql.exec(
193
- `INSERT INTO matrix_effects (job_id, kind, step, effect) VALUES (?, ?, ?, ?)`,
208
+ `INSERT INTO matrix_effects (job_id, kind, step, effect, at, instance_id, execution) VALUES (?, ?, ?, ?, ?, ?, ?)`,
194
209
  running?.jobId ?? "",
195
210
  running?.kind ?? "",
196
211
  running?.step ?? "",
197
212
  effect,
213
+ Date.now(), this.instanceId, running?.execution ?? 0,
198
214
  );
199
215
  if (running) await this.maybeKill("effect", effect);
200
216
  };
201
217
 
202
218
  arm(fault: MatrixFault): void {
203
219
  this.state.storage.sql.exec(
204
- `INSERT INTO matrix_faults (kind, step, mode, effect, remaining) VALUES (?, ?, ?, ?, 1)`,
220
+ `INSERT INTO matrix_faults (kind, step, mode, effect, remaining, resume_delay) VALUES (?, ?, ?, ?, 1, ?)`,
205
221
  fault.kind,
206
222
  fault.step,
207
223
  fault.mode,
208
224
  fault.effect ?? null,
225
+ Math.max(0, Math.min(5_000, fault.resumeDelayMs ?? 0)),
209
226
  );
210
227
  }
211
228
 
@@ -249,12 +266,12 @@ export class MatrixFaults {
249
266
  effects(jobId?: string): MatrixEffect[] {
250
267
  const { sql } = this.state.storage;
251
268
  const rows = jobId
252
- ? sql.exec<{ job_id: string; kind: string; step: string; effect: string }>(
253
- `SELECT job_id, kind, step, effect FROM matrix_effects WHERE job_id = ? ORDER BY seq`,
269
+ ? sql.exec<{ seq: number; at: number; instance_id: string; execution: number; job_id: string; kind: string; step: string; effect: string }>(
270
+ `SELECT * FROM matrix_effects WHERE job_id = ? ORDER BY seq`,
254
271
  jobId,
255
272
  )
256
- : sql.exec<{ job_id: string; kind: string; step: string; effect: string }>(`SELECT job_id, kind, step, effect FROM matrix_effects ORDER BY seq`);
257
- return rows.toArray().map((r) => ({ jobId: r.job_id, kind: r.kind, step: r.step, effect: r.effect }));
273
+ : sql.exec<{ seq: number; at: number; instance_id: string; execution: number; job_id: string; kind: string; step: string; effect: string }>(`SELECT * FROM matrix_effects ORDER BY seq`);
274
+ return rows.toArray().map((r) => ({ jobId: r.job_id, kind: r.kind, step: r.step, effect: r.effect, seq: r.seq, at: r.at, instanceId: r.instance_id, execution: r.execution }));
258
275
  }
259
276
 
260
277
  execs(jobId: string): { step: string; n: number }[] {
@@ -286,8 +303,8 @@ export class MatrixFaults {
286
303
  if (!running) return;
287
304
  const { sql } = this.state.storage;
288
305
  const fault = sql
289
- .exec<{ id: number }>(
290
- `SELECT id FROM matrix_faults WHERE kind = ? AND step = ? AND mode = ? AND IFNULL(effect, '') = ? AND remaining > 0 ORDER BY id LIMIT 1`,
306
+ .exec<{ id: number; resume_delay: number }>(
307
+ `SELECT id, resume_delay FROM matrix_faults WHERE kind = ? AND step = ? AND mode = ? AND IFNULL(effect, '') = ? AND remaining > 0 ORDER BY id LIMIT 1`,
291
308
  running.kind,
292
309
  running.step,
293
310
  mode,
@@ -297,6 +314,7 @@ export class MatrixFaults {
297
314
  if (!fault) return;
298
315
  sql.exec(`UPDATE matrix_faults SET remaining = remaining - 1 WHERE id = ?`, fault.id);
299
316
  const kill = await this.snapshot(running, mode, effect);
317
+ kill.resumeNotBefore = fault.resume_delay ? Date.now() + fault.resume_delay : 0;
300
318
  sql.exec(`INSERT INTO matrix_kills (kill) VALUES (?)`, JSON.stringify(kill));
301
319
  // Everything written so far — the effect itself included — is durable, exactly as a real crash
302
320
  // after it would leave it.
@@ -327,6 +345,10 @@ export class MatrixFaults {
327
345
  }
328
346
  }
329
347
  return {
348
+ instanceId: this.instanceId, execution: running.execution, at: Date.now(), resumeNotBefore: 0,
349
+ stepStartSeq: sql.exec<{ seq: number }>(`SELECT MAX(seq) seq FROM transitions WHERE job_id = ? AND step = ? AND note LIKE 'step-start%'`, running.jobId, running.step).one().seq,
350
+ transitionSeq: sql.exec<{ seq: number }>(`SELECT MAX(seq) seq FROM transitions`).one().seq,
351
+ effectSeq: sql.exec<{ seq: number }>(`SELECT MAX(seq) seq FROM matrix_effects`).one().seq ?? 0,
330
352
  jobId: running.jobId,
331
353
  kind: running.kind,
332
354
  step: running.step,
@@ -80,6 +80,13 @@ function harnessSeedServices(
80
80
  const matrices = new WeakMap<DurableObjectState, { faults: MatrixFaults; registry: JobKindRegistry }>();
81
81
 
82
82
  export class TenantPipeline extends DeployedTenantPipeline {
83
+ constructor(ctx: DurableObjectState, env: unknown) {
84
+ super(ctx, env);
85
+ const resumeAt = matrices.get(ctx)?.faults.kills().at(-1)?.resumeNotBefore ?? 0;
86
+ if (resumeAt > Date.now()) void ctx.blockConcurrencyWhile(async () => {
87
+ await new Promise((resolve) => setTimeout(resolve, resumeAt - Date.now()));
88
+ });
89
+ }
83
90
  private commercePaused = false;
84
91
  private commerceResume: (() => void) | null = null;
85
92
  private commerceOutcome: string = "complete";
@@ -57,7 +57,7 @@ export const PUBLISH_HARNESS_TIMING: PublishTiming = {
57
57
  export const PUBLISH_HARNESS_TUNING: PublishKindTuning = {
58
58
  leaseMs: 3_000,
59
59
  retryBaseMs: 20,
60
- deadlineMs: { synchronous: 30_000, asynchronous: 30_000 },
60
+ deadlineMs: { synchronous: 60_000, asynchronous: 60_000 },
61
61
  hotfixExtraMs: 0,
62
62
  compensationRetryBaseMs: 300,
63
63
  };
@@ -172,6 +172,10 @@ export const SYNTHETIC_COOPERATIVE_HANG_KIND = "synthetic-cooperative-hang";
172
172
  export const LONG_STEP_CHUNKS = 12;
173
173
  export const LONG_STEP_CHUNK_MS = 100;
174
174
  export const LONG_STEP_MAX_EXECUTION_MS = 450;
175
+ export const SYNTHETIC_STEP_CLOCK_KIND = "synthetic-step-clock";
176
+ export const STEP_CLOCK_BASE_MS = 1_000;
177
+ export const STEP_CLOCK_CAPTURE_MS = 1_500;
178
+ export const STEP_CLOCK_LIMIT_MS = 4_000;
175
179
 
176
180
  function engineKinds(state: DurableObjectState): JobKindDefinition<{ mode?: string }>[] {
177
181
  const { sql } = state.storage;
@@ -180,6 +184,34 @@ function engineKinds(state: DurableObjectState): JobKindDefinition<{ mode?: stri
180
184
  const parse = (raw: unknown) => (raw && typeof raw === "object" ? (raw as { mode?: string }) : {});
181
185
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
182
186
  return [
187
+ {
188
+ ...base,
189
+ kind: SYNTHETIC_STEP_CLOCK_KIND,
190
+ parseInput: parse,
191
+ deadlineMs: STEP_CLOCK_BASE_MS,
192
+ leaseMs: 10_000,
193
+ maxAttemptsPerStep: 3,
194
+ steps: [
195
+ { name: "capture", deadlineMs: STEP_CLOCK_LIMIT_MS, run: async (ctx) => {
196
+ const deadline = sql.exec<{ deadline: number }>(`SELECT deadline FROM jobs WHERE job_id = ?`, ctx.jobId).one().deadline;
197
+ ctx.setProduces({ captureStartedAt: ctx.now(), captureDeadline: deadline });
198
+ if (ctx.input.mode === "evict" && !ctx.checkpoint) {
199
+ ctx.saveCheckpoint({ evicted: true });
200
+ await ctx.flush();
201
+ state.abort("synthetic eviction during bounded step clock");
202
+ }
203
+ if (ctx.input.mode === "hang") await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve(), { once: true }));
204
+ else await sleep(STEP_CLOCK_CAPTURE_MS);
205
+ return { kind: "done" as const };
206
+ } },
207
+ { name: "after", run: async (ctx) => {
208
+ const deadline = sql.exec<{ deadline: number }>(`SELECT deadline FROM jobs WHERE job_id = ?`, ctx.jobId).one().deadline;
209
+ ctx.setProduces({ afterStartedAt: ctx.now(), afterDeadline: deadline });
210
+ await new Promise<void>((resolve) => ctx.signal.addEventListener("abort", () => resolve(), { once: true }));
211
+ return { kind: "done" as const };
212
+ } },
213
+ ],
214
+ },
183
215
  {
184
216
  kind: SYNTHETIC_OPTIONAL_TAIL_KIND,
185
217
  parseInput: parse,
@@ -561,9 +561,15 @@ export function chainViolations(log: TransitionView[]): string[] {
561
561
  }
562
562
 
563
563
  /** Across every job of the tenant: between a step's start and its outcome, no other step starts. */
564
- export function overlapViolations(all: TransitionView[]): string[] {
564
+ export function overlapViolations(all: TransitionView[], kills: readonly MatrixKill[] = [], effects: readonly MatrixEffect[] = []): string[] {
565
565
  let open: TransitionView | null = null;
566
566
  for (const t of all) {
567
+ // A recorded abort ends only its exact execution; any later effect keeps the overlap visible.
568
+ if (open && kills.some((kill) => ["before", "after", "resume", "effect"].includes(kill.mode) && !!kill.instanceId && kill.execution > 0 &&
569
+ kill.jobId === open!.jobId && kill.step === open!.step && kill.stepStartSeq === open!.seq &&
570
+ kill.transitionSeq >= open!.seq && kill.transitionSeq < t.seq &&
571
+ !effects.some((effect) => effect.jobId === kill.jobId && effect.step === kill.step &&
572
+ effect.instanceId === kill.instanceId && effect.execution === kill.execution && effect.seq > kill.effectSeq))) open = null;
567
573
  if (t.note?.startsWith("step-start")) {
568
574
  if (open && open.jobId !== t.jobId) return [`step ${t.step} of ${t.jobId} started while ${open.step} of ${open.jobId} was running`];
569
575
  open = t;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.11.16",
3
+ "version": "4.12.1",
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",
@@ -10,6 +10,17 @@
10
10
  */
11
11
  import type { ImageDependency, ImageDependencyArtifact } from "./image-dependencies.js";
12
12
 
13
+ /** Fingerprint exact source bytes without copying a whole revision into one JSON string. */
14
+ export async function fingerprintCaptureArtifacts(artifacts: readonly ImageDependencyArtifact[]): Promise<string> {
15
+ const hash = async (value: string) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)))]
16
+ .map((byte) => byte.toString(16).padStart(2, "0")).join("");
17
+ const identities: Array<{ path: string; kind: ImageDependencyArtifact["kind"]; digest: string }> = [];
18
+ for (const artifact of [...artifacts].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
19
+ identities.push({ path: artifact.path, kind: artifact.kind, digest: await hash(artifact.content) });
20
+ }
21
+ return `sha256:${await hash(JSON.stringify(identities))}`;
22
+ }
23
+
13
24
  export type CaptureRewrite = {
14
25
  /** Effect-only; never log or render. */
15
26
  canonicalLocator: string;
@@ -539,7 +550,12 @@ export function applyCaptureRewrites(
539
550
  artifacts: readonly ImageDependencyArtifact[],
540
551
  rewrites: readonly CaptureRewrite[],
541
552
  ): ImageDependencyArtifact[] {
542
- if (rewrites.length === 0) return artifacts.map((a) => ({ ...a }));
553
+ return artifacts.map(createCaptureArtifactRewriter(rewrites));
554
+ }
555
+
556
+ /** Prepare locator matching once while allowing callers to persist one rewritten artifact at a time. */
557
+ export function createCaptureArtifactRewriter(rewrites: readonly CaptureRewrite[]): (artifact: ImageDependencyArtifact) => ImageDependencyArtifact {
558
+ if (rewrites.length === 0) return (artifact) => ({ ...artifact });
543
559
  const prepared = rewrites.map((rewrite) => {
544
560
  // Longer needles first so `/path?v=1` wins over slash-stripped `path`.
545
561
  // Include `&amp;` spellings so HTML-escaped query joins still match.
@@ -554,7 +570,7 @@ export function applyCaptureRewrites(
554
570
  const exact = new Set((rewrite.exactAliases ?? []).flatMap((n) => [n, ...htmlAmpVariants(n)]));
555
571
  return { rewrite, needles, key, exact };
556
572
  });
557
- return artifacts.map((artifact) => {
573
+ return (artifact) => {
558
574
  let content = artifact.content;
559
575
  for (const { rewrite, needles, key, exact } of prepared) {
560
576
  if (key !== null && !content.includes(key)) continue;
@@ -565,7 +581,7 @@ export function applyCaptureRewrites(
565
581
  // Guard: slash-stripped local needles must not leave a protocol-relative URL.
566
582
  content = content.replace(/\/{2,}_a\//g, "/_a/");
567
583
  return { ...artifact, content };
568
- });
584
+ };
569
585
  }
570
586
 
571
587
  /**
@@ -366,14 +366,18 @@ export async function resolvePublishedArtifactKey(
366
366
  if (!selection) return null;
367
367
  if (selection.source !== "channel") return customizationArtifactKey(tenantId, selection.versionId);
368
368
 
369
- // Versioned model engaged for this channel — authoritative. The shared preview renders a
370
- // version's captured images the way a pinned preview of it does; `live` never reads the
371
- // overlay (its public path is the published bundle, which carries its own rewrites).
369
+ // Both hosted live and preview render the exact channel revision's sealed capture.
372
370
  const { versionId } = await resolveServableChannelVersion(get, tenantId, selection.channel);
373
371
  if (!versionId) return null;
374
- return selection.channel === "preview"
375
- ? resolveCapturedVersionArtifactKey(get, tenantId, versionId, path)
376
- : resolveVersionArtifactKey(get, tenantId, versionId, path);
372
+ return resolveCapturedVersionArtifactKey(get, tenantId, versionId, path);
373
+ }
374
+
375
+ /** Exact revision whose sealed originals a hosted request may read. */
376
+ export async function resolveCapturedRequestVersion(input: {
377
+ get: KvGet; tenantId: string; env: CustomizationEnv; candidateVersionId?: string | null; cookieVersionId?: string | null;
378
+ }): Promise<string | null> {
379
+ return input.candidateVersionId ?? input.cookieVersionId ??
380
+ (await resolveServableChannelVersion(input.get, input.tenantId, channelFromEnv(input.env))).versionId;
377
381
  }
378
382
 
379
383
  /**
@@ -32,6 +32,37 @@ export interface ImageDependencyArtifact {
32
32
  kind?: ImageArtifactKind;
33
33
  }
34
34
 
35
+ export const IMAGE_TYPES = ["catalog", "blog", "pages", "other"] as const;
36
+ export type ImageType = typeof IMAGE_TYPES[number];
37
+ export type ImageTypeCounts = Record<ImageType, { files: number; references: number; uncaptured: number }>;
38
+
39
+ export function imageTypeOf(path: string): ImageType {
40
+ if (path.startsWith("content/catalog/")) return "catalog";
41
+ if (path.startsWith("content/blog/")) return "blog";
42
+ if (path.startsWith("content/pages/") || path.startsWith("content/pages-html/")) return "pages";
43
+ return "other";
44
+ }
45
+
46
+ export function imageCensusByType(input: {
47
+ artifacts: readonly ImageDependencyArtifact[]; tenantId: string; tenantOrigins: readonly string[]; skipped?: readonly string[];
48
+ }): ImageTypeCounts {
49
+ if (!Array.isArray(input.tenantOrigins)) throw new TypeError("tenantOrigins is required");
50
+ const out = {} as ImageTypeCounts;
51
+ for (const type of IMAGE_TYPES) {
52
+ const group = input.artifacts.filter((artifact) => imageTypeOf(artifact.path) === type);
53
+ const skipped = (input.skipped ?? []).filter((path) => imageTypeOf(path) === type).length;
54
+ const census = scanImageDependencies(group, { tenantId: input.tenantId, tenantOrigins: input.tenantOrigins }).census;
55
+ out[type] = { files: group.length + skipped, references: census.references + skipped,
56
+ uncaptured: census.captureRequired + census.securityErrors + census.deferred + skipped };
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /** Separate from mutable dual-run evidence: one immutable source revision's measured image ownership. */
62
+ export function hostedImageCensusKey(tenantId: string, revisionId: string): string {
63
+ return `image-census:${tenantId}:${revisionId}`;
64
+ }
65
+
35
66
  export interface PlatformHostedImageConfig {
36
67
  /** Exact already-hosted references. Query/fragment spelling is ignored. */
37
68
  references?: readonly string[];
@@ -2,8 +2,8 @@
2
2
  * Revision-bound overlay of capture-rewritten preview artifacts (read path).
3
3
  *
4
4
  * Site-version manifests are immutable; successful `/_a/<sha>` rewrites are
5
- * stored as content-addressed artifact bytes plus this overlay map. Preview
6
- * reads prefer the overlay so candidate documents reference platform-owned URLs.
5
+ * stored as content-addressed artifact bytes plus this overlay map. Hosted live and preview
6
+ * reads prefer their exact revision's overlay so documents reference platform-owned URLs.
7
7
  * The record and its key-level resolution live in `customization-runtime.ts`,
8
8
  * which every versioned read goes through.
9
9
  */
@@ -12,7 +12,9 @@ import {
12
12
  customizationArtifactKey,
13
13
  readImagePreviewRewriteOverlay,
14
14
  themeArtifactPath,
15
+ type ImagePreviewRewriteOverlay,
15
16
  } from "./customization-runtime.js";
17
+ import { sha256Hex } from "./hash.js";
16
18
 
17
19
  export {
18
20
  imagePreviewRewriteOverlayKey,
@@ -20,6 +22,13 @@ export {
20
22
  type ImagePreviewRewriteOverlay,
21
23
  } from "./customization-runtime.js";
22
24
 
25
+ /** Bind measured counts to the exact immutable rewrite map used by live reads. */
26
+ export async function digestImagePreviewRewriteOverlay(overlay: ImagePreviewRewriteOverlay | null): Promise<string | null> {
27
+ if (!overlay) return null;
28
+ const artifacts = Object.entries(overlay.artifacts).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
29
+ return `sha256:${await sha256Hex(JSON.stringify({ revisionId: overlay.revisionId, captureManifestDigest: overlay.captureManifestDigest, artifacts }))}`;
30
+ }
31
+
23
32
  /** Map a repo-relative capture artifact path to the preview manifest artifact path. */
24
33
  export function previewManifestPathFor(tenantId: string, repoPath: string): string {
25
34
  if (repoPath === "theme.json") return themeArtifactPath(tenantId);