@tokenoftrust/storefront-runner 2.0.47 → 2.0.48

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.
@@ -10,7 +10,7 @@ const ROOT_SOURCES: Readonly<Record<string, PublicationSpaceSourceV1>> = {
10
10
  defaultBranch: "main",
11
11
  },
12
12
  "sample-store.com": { forge: "gitea", repo: "sample-store.com", defaultBranch: "main" },
13
- "sample-store.e2e.test": { forge: "fixture", repo: "sample-store.e2e.test", defaultBranch: "main" },
13
+ "sample-store.e2e.test": { forge: "gitea", repo: "sample-store.e2e.test", defaultBranch: "main" },
14
14
  "verify.e2e.test": { forge: "fixture", repo: "verify.e2e.test", defaultBranch: "main" },
15
15
  "fixture.e2e.test": { forge: "fixture", repo: "fixture.e2e.test", defaultBranch: "main" },
16
16
  "chrome.e2e.test": { forge: "fixture", repo: "chrome.e2e.test", defaultBranch: "main" },
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Worker-safe serve of revision-bound captured originals.
3
+ *
4
+ * Does not import Node capture — R2 + Web Crypto only. Bytes are served only
5
+ * when the sealed revision manifest names the address.
6
+ */
7
+ import { sha256Hex } from "@tot/public-runtime";
8
+
9
+ const ADDRESS_RE = /^[0-9a-f]{64}$/;
10
+ /** Hostname-like tenant ids (no scheme, no path separators). */
11
+ const TENANT_ID_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/i;
12
+ /** Hostname-like revision ids, or production `sha256:<64 hex>`. */
13
+ const REVISION_ID_RE =
14
+ /^(?:[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?|sha256:[0-9a-f]{64})$/i;
15
+
16
+ function isSafeTenantId(tenantId: string): boolean {
17
+ return TENANT_ID_RE.test(tenantId);
18
+ }
19
+ function isSafeRevisionId(revisionId: string): boolean {
20
+ return REVISION_ID_RE.test(revisionId);
21
+ }
22
+ /** Same key layout as private-controlplane original-image-capture manifestKey. */
23
+ function originalImageManifestObjectKey(tenantId: string, revisionId: string): string {
24
+ return `original-images/${tenantId}/revisions/${revisionId}/manifest.json`;
25
+ }
26
+
27
+ const EXT_TYPE: Record<string, string> = {
28
+ ".png": "image/png",
29
+ ".jpg": "image/jpeg",
30
+ ".jpeg": "image/jpeg",
31
+ ".gif": "image/gif",
32
+ ".webp": "image/webp",
33
+ ".avif": "image/avif",
34
+ };
35
+
36
+ export type CapturedOriginalBucket = {
37
+ get(key: string): Promise<unknown>;
38
+ };
39
+
40
+ type ManifestEntry = {
41
+ address: string;
42
+ byteLength: number;
43
+ mediaType: string;
44
+ };
45
+
46
+ function objectKey(tenantId: string, address: string): string {
47
+ return `original-images/${tenantId}/objects/${address}`;
48
+ }
49
+
50
+ /** Parse `/_a/<sha256>[.ext]` — null when the path is not a pinned asset URL. */
51
+ export function parsePinnedImagePath(pathname: string): { address: string; ext: string } | null {
52
+ const match = pathname.match(/^\/_a\/([0-9a-f]{64})(\.[A-Za-z0-9]+)?$/);
53
+ if (!match) return null;
54
+ return { address: match[1]!, ext: match[2] ?? "" };
55
+ }
56
+
57
+ async function readManifestEntries(
58
+ bucket: CapturedOriginalBucket,
59
+ tenantId: string,
60
+ revisionId: string,
61
+ ): Promise<ManifestEntry[] | null> {
62
+ if (!isSafeTenantId(tenantId) || !isSafeRevisionId(revisionId)) return null;
63
+ try {
64
+ const object = await bucket.get(originalImageManifestObjectKey(tenantId, revisionId));
65
+ if (!object || typeof object !== "object") return null;
66
+ const body = object as { arrayBuffer?: () => Promise<ArrayBuffer> };
67
+ if (typeof body.arrayBuffer !== "function") return null;
68
+ const parsed = JSON.parse(new TextDecoder().decode(new Uint8Array(await body.arrayBuffer()))) as {
69
+ version?: unknown;
70
+ revisionId?: unknown;
71
+ entries?: unknown;
72
+ };
73
+ if (parsed.version !== 1 || parsed.revisionId !== revisionId || !Array.isArray(parsed.entries)) {
74
+ return null;
75
+ }
76
+ const entries: ManifestEntry[] = [];
77
+ for (const raw of parsed.entries) {
78
+ if (!raw || typeof raw !== "object") return null;
79
+ const entry = raw as Partial<ManifestEntry>;
80
+ if (typeof entry.address !== "string" || !ADDRESS_RE.test(entry.address)) return null;
81
+ if (typeof entry.byteLength !== "number" || typeof entry.mediaType !== "string") return null;
82
+ entries.push({
83
+ address: entry.address,
84
+ byteLength: entry.byteLength,
85
+ mediaType: entry.mediaType,
86
+ });
87
+ }
88
+ return entries;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Serve a captured original for the exact tenant+revision, or null to fall through.
96
+ * Missing/unbound/tampered addresses are 404 with no locator or digest in the body.
97
+ */
98
+ export async function tryServeCapturedOriginal(input: {
99
+ bucket: CapturedOriginalBucket;
100
+ tenantId: string;
101
+ revisionId: string;
102
+ pathname: string;
103
+ method?: string;
104
+ }): Promise<Response | null> {
105
+ const parsed = parsePinnedImagePath(input.pathname);
106
+ if (!parsed) return null;
107
+ if (!isSafeTenantId(input.tenantId) || !isSafeRevisionId(input.revisionId)) {
108
+ return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
109
+ }
110
+
111
+ const entries = await readManifestEntries(input.bucket, input.tenantId, input.revisionId);
112
+ const entry = entries?.find((item) => item.address === parsed.address);
113
+ if (!entry) {
114
+ return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
115
+ }
116
+
117
+ try {
118
+ const object = await input.bucket.get(objectKey(input.tenantId, parsed.address));
119
+ if (!object || typeof object !== "object") {
120
+ return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
121
+ }
122
+ const body = object as { arrayBuffer?: () => Promise<ArrayBuffer> };
123
+ if (typeof body.arrayBuffer !== "function") {
124
+ return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
125
+ }
126
+ const bytes = new Uint8Array(await body.arrayBuffer());
127
+ if (bytes.byteLength !== entry.byteLength) {
128
+ return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
129
+ }
130
+ const digest = await sha256Hex(bytes);
131
+ if (digest !== parsed.address) {
132
+ return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
133
+ }
134
+ const contentType = EXT_TYPE[parsed.ext.toLowerCase()] ?? entry.mediaType;
135
+ const headers: Record<string, string> = {
136
+ "content-type": contentType,
137
+ "cache-control": "public, max-age=31536000, immutable",
138
+ "x-tot-original-image": "revision-bound",
139
+ "content-length": String(bytes.byteLength),
140
+ };
141
+ const method = (input.method ?? "GET").toUpperCase();
142
+ return new Response(method === "HEAD" ? null : bytes, { status: 200, headers });
143
+ } catch {
144
+ return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
145
+ }
146
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Resolve the candidate/revision asset pin from preview cookies.
3
+ *
4
+ * Candidate and revision renderers set `tot_preview_version` (the exact sha) and
5
+ * `tot_preview` (capability token). Subresource GETs (img `/_a/…`, `/tenants/…`)
6
+ * carry those cookies but not `__tot_candidate_pin`, so this is the revision
7
+ * authority for revision-bound captured originals and preview assets.
8
+ */
9
+ import { verifyPreviewToken } from "@tot/public-runtime";
10
+
11
+ const SAFE_VERSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
12
+
13
+ export async function resolvePreviewVersionFromCookies(input: {
14
+ tenantId: string;
15
+ previewVersionCookie: string | null | undefined;
16
+ previewTokenCookie: string | null | undefined;
17
+ secret: string | null | undefined;
18
+ nowSeconds?: number;
19
+ }): Promise<string | null> {
20
+ const versionId = input.previewVersionCookie?.trim() || null;
21
+ const token = input.previewTokenCookie?.trim() || null;
22
+ if (!versionId || !token || !input.tenantId || !input.secret) return null;
23
+ if (!SAFE_VERSION_ID.test(versionId) || versionId.includes("..")) return null;
24
+ const verified = await verifyPreviewToken(
25
+ input.secret,
26
+ token,
27
+ input.nowSeconds ?? Math.floor(Date.now() / 1000),
28
+ );
29
+ if (!verified.ok || verified.claims.tenantId !== input.tenantId) return null;
30
+ return versionId;
31
+ }
@@ -196,7 +196,7 @@ export function isDevCliSessionPath(pathname: string): boolean {
196
196
  * it: an unauthenticated request still gets a 401 from the route's own check.
197
197
  */
198
198
  export function isStaticBundleUploadPath(pathname: string): boolean {
199
- return pathname === "/api/publish/stage-bundle";
199
+ return pathname === "/api/publish/stage-bundle" || pathname === "/api/publish/captured-original";
200
200
  }
201
201
 
202
202
  /**
@@ -33,6 +33,7 @@ import { renderRawChrome } from "../rawChrome.js";
33
33
  import { parseChromeConfig } from "../chrome/parseConfig.js";
34
34
  import { parseChromeAssignments } from "../chrome/model.js";
35
35
  import { stampTrackingIdsAtPublish } from "../tracking/publishSafetyNet.js";
36
+ import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
36
37
 
37
38
  /** Safe, always-valid fallback when neither the tenant's own chrome.json nor
38
39
  * the default scope's validates — the storefront must always render. */
@@ -702,11 +703,13 @@ export class VersionPinnedContentProvider implements ContentProvider {
702
703
  ) {}
703
704
 
704
705
  private pinnedRaw(relative: string): Promise<string | null> {
705
- return readVersionArtifact(
706
+ const path = contentArtifactPath(this.tenantId, relative);
707
+ return readWithImagePreviewRewriteOverlay(
706
708
  this.get,
707
709
  this.tenantId,
708
710
  this.versionId,
709
- contentArtifactPath(this.tenantId, relative),
711
+ path,
712
+ () => readVersionArtifact(this.get, this.tenantId, this.versionId, path),
710
713
  );
711
714
  }
712
715
 
@@ -231,6 +231,19 @@ export const onRequest = defineMiddleware(async (context, next) => {
231
231
  // keep off the public path. Managed installs are not consulted: they inject
232
232
  // document markup, never asset bytes, so this stays a D1-free path.
233
233
  if (branch === "static-passthrough") {
234
+ const candidatePin = await resolveCandidateContentPin(url, tenant.tenant_id);
235
+ const { resolvePreviewVersionFromCookies } = await import("@/lib/assets/preview-version-cookie");
236
+ const cookieRevision = await resolvePreviewVersionFromCookies({
237
+ tenantId: tenant.tenant_id,
238
+ previewVersionCookie: context.cookies.get("tot_preview_version")?.value,
239
+ previewTokenCookie: context.cookies.get("tot_preview")?.value,
240
+ secret: await readEnv("CUSTOMIZATION_PREVIEW_SECRET"),
241
+ });
242
+ const capturedRevisionId = candidatePin?.versionId ?? cookieRevision;
243
+ const originalsBucket =
244
+ capturedRevisionId
245
+ ? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
246
+ : null;
234
247
  const bundled = await tryServeStaticBundle({
235
248
  request,
236
249
  url,
@@ -241,9 +254,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
241
254
  isPreviewHost: host.toLowerCase().startsWith("preview."),
242
255
  hasPreviewToken:
243
256
  url.searchParams.has("__preview") || !!context.cookies.get("tot_preview")?.value,
244
- hasCandidatePin: !!(await resolveCandidateContentPin(url, tenant.tenant_id)),
257
+ hasCandidatePin: !!candidatePin,
245
258
  hasDraftCookie: !!context.cookies.get(DEV_DRAFT_COOKIE)?.value,
246
259
  hasManagedInstalls: false,
260
+ ...(capturedRevisionId && originalsBucket
261
+ ? {
262
+ capturedOriginal: {
263
+ bucket: originalsBucket as { get(key: string): Promise<unknown> },
264
+ revisionId: capturedRevisionId,
265
+ },
266
+ }
267
+ : {}),
247
268
  });
248
269
  if (bundled) return bundled;
249
270
  const rootLocation = platformStaticRootLocationV1({
@@ -12,7 +12,7 @@ import type { APIContext, MiddlewareNext } from "astro";
12
12
  import { referenceTheme } from "@/themes/reference";
13
13
  import { mergeTheme, themeToCssVars } from "@/themes/schema";
14
14
  import { createToTClient } from "@/lib/tot/ToTClient";
15
- import { readEnv } from "@/lib/runtimeEnv";
15
+ import { readEnv, readR2 } from "@/lib/runtimeEnv";
16
16
  import { tryServeStaticBundle } from "./staticBundle.js";
17
17
  import { resolveCandidateContentPin } from "./candidatePin.js";
18
18
  import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
@@ -248,6 +248,19 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
248
248
  // Digest-addressed static-bundle serving. Deliberately before the edge page
249
249
  // cache/catalog/theme machinery. A selected publication bypasses this legacy
250
250
  // whole-site bundle until B1 can compose mounted spaces into it reproducibly.
251
+ const { resolvePreviewVersionFromCookies } = await import("@/lib/assets/preview-version-cookie");
252
+ const cookieRevision = await resolvePreviewVersionFromCookies({
253
+ tenantId: tenant.tenant_id,
254
+ previewVersionCookie: context.cookies.get("tot_preview_version")?.value,
255
+ previewTokenCookie: cookieToken,
256
+ secret: previewSecret,
257
+ nowSeconds,
258
+ });
259
+ const capturedRevisionId = candidatePin?.versionId ?? cookieRevision;
260
+ const originalsBucket =
261
+ capturedRevisionId
262
+ ? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
263
+ : null;
251
264
  const bundled = publicationSource.status === "legacy-root" ? await tryServeStaticBundle({
252
265
  request,
253
266
  url,
@@ -260,6 +273,14 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
260
273
  hasCandidatePin: !!candidatePin,
261
274
  hasDraftCookie: !!draftCookie,
262
275
  hasManagedInstalls: managedIntegrationDeclarations.length > 0,
276
+ ...(capturedRevisionId && originalsBucket
277
+ ? {
278
+ capturedOriginal: {
279
+ bucket: originalsBucket as { get(key: string): Promise<unknown> },
280
+ revisionId: capturedRevisionId,
281
+ },
282
+ }
283
+ : {}),
263
284
  }) : null;
264
285
  if (bundled) return bundled;
265
286
 
@@ -31,6 +31,14 @@ export interface StaticBundleAttemptInput {
31
31
  hasDraftCookie: boolean;
32
32
  /** Managed installs inject document markup, so a bundled DOCUMENT would be stale. */
33
33
  hasManagedInstalls: boolean;
34
+ /**
35
+ * When set, a `/_a/<sha>` miss falls through to revision-bound captured
36
+ * originals (hosted preview image capture) before returning 404.
37
+ */
38
+ capturedOriginal?: {
39
+ bucket: { get(key: string): Promise<unknown> };
40
+ revisionId: string;
41
+ };
34
42
  }
35
43
 
36
44
  /**
@@ -71,10 +79,33 @@ export async function tryServeStaticBundle(
71
79
  url.pathname,
72
80
  method,
73
81
  );
74
- if (pinned) {
75
- if (pinnedKey && cache && pinned.ok) await putCachedPage(cache, pinnedKey, pinned);
82
+ if (pinned && pinned.ok) {
83
+ if (pinnedKey && cache) await putCachedPage(cache, pinnedKey, pinned);
76
84
  return pinned;
77
85
  }
86
+ // `/_a/` miss: try revision-bound captured originals before the pinned 404.
87
+ if (input.capturedOriginal && url.pathname.startsWith("/_a/")) {
88
+ const { tryServeCapturedOriginal } = await import("@/lib/assets/captured-original-serve");
89
+ const captured = await tryServeCapturedOriginal({
90
+ bucket: input.capturedOriginal.bucket,
91
+ tenantId: tenant.tenant_id,
92
+ revisionId: input.capturedOriginal.revisionId,
93
+ pathname: url.pathname,
94
+ method,
95
+ });
96
+ if (captured) return captured;
97
+ }
98
+ if (pinned) return pinned;
99
+ } else if (input.capturedOriginal && url.pathname.startsWith("/_a/")) {
100
+ const { tryServeCapturedOriginal } = await import("@/lib/assets/captured-original-serve");
101
+ const captured = await tryServeCapturedOriginal({
102
+ bucket: input.capturedOriginal.bucket,
103
+ tenantId: tenant.tenant_id,
104
+ revisionId: input.capturedOriginal.revisionId,
105
+ pathname: url.pathname,
106
+ method,
107
+ });
108
+ if (captured) return captured;
78
109
  }
79
110
  }
80
111
 
@@ -13,38 +13,50 @@
13
13
  * and a versioned tenant (skipped from baking via a `.tot/assets-versioned`
14
14
  * marker) falls through to R2. When the tenant has no published asset version,
15
15
  * `serveTenantAsset` returns null → 404 (nothing published yet).
16
+ *
17
+ * Preview sessions also consult the image-capture rewrite overlay so rewritten
18
+ * `public/*.css` (and similar text assets) serve `/_a/<sha>` references.
16
19
  */
17
20
  import type { APIContext } from "astro";
18
21
  import {
22
+ assetContentType,
23
+ parseTenantAssetPath,
19
24
  serveTenantAsset,
20
- verifyPreviewToken,
21
25
  type R2BucketLike,
22
26
  } from "@tot/public-runtime";
27
+ import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
23
28
  import { readR2, readKv, readEnv } from "@/lib/env";
29
+ import { resolvePreviewVersionFromCookies } from "@/lib/assets/preview-version-cookie";
24
30
 
25
31
  export const prerender = false;
26
32
 
27
- /** A versionId is a content sha — restrict to a safe R2 key segment (no traversal). */
28
- const SAFE_VERSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
29
-
30
- /**
31
- * Resolve a CANDIDATE-preview version override for this asset request, or null.
32
- * A candidate/revision page sets `tot_preview_version` (its versionId) + the
33
- * `tot_preview` capability token; when the token verifies for THIS tenant, we serve
34
- * that exact snapshot (`tenants/<id>/<versionId>/…`) instead of the channel pointer,
35
- * so a candidate's NEW assets — unreachable via the never-flipped channel — resolve.
36
- * Token-gated + tenant-matched so it can never serve another tenant's snapshot.
37
- */
38
- async function resolvePreviewVersion(context: APIContext, urlTenant: string): Promise<string | null> {
39
- const versionId = context.cookies.get("tot_preview_version")?.value ?? null;
40
- const token = context.cookies.get("tot_preview")?.value ?? null;
41
- if (!versionId || !token || !urlTenant) return null;
42
- if (!SAFE_VERSION_ID.test(versionId) || versionId.includes("..")) return null;
43
- const secret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
44
- if (!secret) return null;
45
- const verified = await verifyPreviewToken(secret, token, Math.floor(Date.now() / 1000));
46
- if (!verified.ok || verified.claims.tenantId !== urlTenant) return null;
47
- return versionId;
33
+ async function tryServePreviewRewriteOverlay(input: {
34
+ kvGet: (key: string) => Promise<string | null>;
35
+ tenantId: string;
36
+ revisionId: string;
37
+ pathname: string;
38
+ }): Promise<Response | null> {
39
+ const parsed = parseTenantAssetPath(input.pathname);
40
+ if (!parsed || parsed.tenantId !== input.tenantId) return null;
41
+ // Capture overlays store repo paths as tenants/<id>/public/<rel>.
42
+ const overlayPath = `tenants/${input.tenantId}/public/${parsed.rel}`;
43
+ const text = await readWithImagePreviewRewriteOverlay(
44
+ input.kvGet,
45
+ input.tenantId,
46
+ input.revisionId,
47
+ overlayPath,
48
+ async () => null,
49
+ );
50
+ if (text == null) return null;
51
+ return new Response(text, {
52
+ status: 200,
53
+ headers: {
54
+ "content-type": assetContentType(parsed.rel),
55
+ "cache-control": "no-store",
56
+ "x-robots-tag": "noindex, nofollow",
57
+ "x-tot-image-preview-rewrite": "1",
58
+ },
59
+ });
48
60
  }
49
61
 
50
62
  export async function GET(context: APIContext): Promise<Response> {
@@ -60,7 +72,24 @@ export async function GET(context: APIContext): Promise<Response> {
60
72
  });
61
73
  }
62
74
 
63
- const previewVersionId = await resolvePreviewVersion(context, context.params.id ?? "");
75
+ const urlTenant = context.params.id ?? "";
76
+ const previewVersionId = await resolvePreviewVersionFromCookies({
77
+ tenantId: urlTenant,
78
+ previewVersionCookie: context.cookies.get("tot_preview_version")?.value,
79
+ previewTokenCookie: context.cookies.get("tot_preview")?.value,
80
+ secret: await readEnv("CUSTOMIZATION_PREVIEW_SECRET"),
81
+ });
82
+
83
+ const pathname = new URL(context.request.url).pathname;
84
+ if (previewVersionId) {
85
+ const overlay = await tryServePreviewRewriteOverlay({
86
+ kvGet: (key) => kv.get(key),
87
+ tenantId: urlTenant,
88
+ revisionId: previewVersionId,
89
+ pathname,
90
+ });
91
+ if (overlay) return overlay;
92
+ }
64
93
 
65
94
  const res = await serveTenantAsset(
66
95
  {
@@ -71,7 +100,7 @@ export async function GET(context: APIContext): Promise<Response> {
71
100
  ...(previewVersionId ? { versionId: previewVersionId } : {}),
72
101
  ifNoneMatch: context.request.headers.get("if-none-match"),
73
102
  },
74
- new URL(context.request.url).pathname,
103
+ pathname,
75
104
  );
76
105
 
77
106
  // A candidate-preview asset must NEVER be edge/CDN-cached under its tokenless URL
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.47",
3
+ "version": "2.0.48",
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",
@@ -12,6 +12,9 @@
12
12
  "./blog-visibility": "./src/blog-visibility.mjs",
13
13
  "./publication-redirects": "./src/publication-redirects.mjs",
14
14
  "./image-dependencies": "./src/image-dependencies.ts",
15
+ "./image-finding-dependencies": "./src/image-finding-dependencies.ts",
16
+ "./image-finding-acceptance": "./src/image-finding-acceptance.ts",
17
+ "./image-preview-rewrite-overlay": "./src/image-preview-rewrite-overlay.ts",
15
18
  "./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
16
19
  "./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
17
20
  "./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs"
@@ -0,0 +1,11 @@
1
+ import type { ImageDependency } from "./image-dependencies.js";
2
+
3
+ /** Private locator-free identity, stable when artifact order changes. */
4
+ export async function digestImageDependencyIdentity(
5
+ dependency: Pick<ImageDependency, "surface" | "reason" | "canonicalLocator">,
6
+ ): Promise<string> {
7
+ if (!dependency.reason) throw new Error("image dependency has no unresolved reason");
8
+ const input = `${dependency.surface}\0${dependency.reason}\0${dependency.canonicalLocator}`;
9
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)));
10
+ return `sha256:${[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
11
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Exact image-finding acceptance helpers: canonical digests and the application /
3
+ * publication overridability classifiers. Findings themselves never carry a locator,
4
+ * tenant id, path, or content digest — only stable ids, codes, and reasons.
5
+ */
6
+ import { sha256Hex } from "./hash.js";
7
+ import type {
8
+ ImageDependencyFinding,
9
+ ImageDependencyReason,
10
+ } from "./image-dependencies.js";
11
+
12
+ export type ImageApplicationClass = ImageDependencyFinding["authoring"];
13
+ export type ImagePublicationClass = ImageDependencyFinding["hostedPreview"];
14
+
15
+ /** Capture findings are never overridable; every reason is a negative control. */
16
+ export const NON_OVERRIDABLE_CAPTURE_REASONS = [
17
+ "symlink",
18
+ "fifo",
19
+ "device",
20
+ "escape",
21
+ "changed-file",
22
+ "unsafe-scheme",
23
+ "userinfo",
24
+ "nonstandard-port",
25
+ "blocked-address",
26
+ "redirect-limit",
27
+ "timeout",
28
+ "too-large",
29
+ "mime",
30
+ "magic",
31
+ "active-content",
32
+ "pixels",
33
+ "tampered",
34
+ "fetch-failed",
35
+ "unsupported",
36
+ "store-failed",
37
+ "manifest-mismatch",
38
+ "unbound",
39
+ ] as const;
40
+
41
+ export type NonOverridableCaptureReason = (typeof NON_OVERRIDABLE_CAPTURE_REASONS)[number];
42
+
43
+ /** Dependency reasons that are security/isolation errors in both classifiers. */
44
+ export const NON_OVERRIDABLE_DEPENDENCY_REASONS = [
45
+ "cross-tenant",
46
+ "tenant-escape",
47
+ "unsafe-scheme",
48
+ "malformed",
49
+ ] as const;
50
+
51
+ export type NonOverridableDependencyReason = (typeof NON_OVERRIDABLE_DEPENDENCY_REASONS)[number];
52
+
53
+ /** Safe unresolved / deferred image reasons an owner may accept. */
54
+ export const OVERRIDABLE_DEPENDENCY_REASONS = ["third-party", "local", "unsupported"] as const;
55
+
56
+ export type ImageFindingIdentity = {
57
+ id: string;
58
+ code: string;
59
+ reason: string;
60
+ };
61
+
62
+ /** Stable publish/operation finding id for an overridable image finding. */
63
+ export function formatOverridableImageFindingId(finding: Pick<ImageDependencyFinding, "id">): string {
64
+ return `IMAGE: ${finding.id}`;
65
+ }
66
+
67
+ /** Stable publish/operation finding id for a non-overridable image finding. */
68
+ export function formatBlockingImageFindingId(
69
+ finding: Pick<ImageDependencyFinding, "id" | "code">,
70
+ ): string {
71
+ return `${finding.code}: ${finding.id}`;
72
+ }
73
+
74
+ export function formatImageFindingId(finding: Pick<ImageDependencyFinding, "id" | "code" | "overridable">): string {
75
+ return finding.overridable
76
+ ? formatOverridableImageFindingId(finding)
77
+ : formatBlockingImageFindingId(finding);
78
+ }
79
+
80
+ /** Application (authoring) classifier — warning vs non-overridable security. */
81
+ export function classifyImageFindingApplication(
82
+ reason: ImageDependencyReason,
83
+ ): ImageApplicationClass {
84
+ return (NON_OVERRIDABLE_DEPENDENCY_REASONS as readonly string[]).includes(reason)
85
+ ? "non-overridable-security-error"
86
+ : "warning";
87
+ }
88
+
89
+ /** Publication / hosted-preview classifier — overridable quality vs security. */
90
+ export function classifyImageFindingPublication(
91
+ reason: ImageDependencyReason,
92
+ ): ImagePublicationClass {
93
+ return (NON_OVERRIDABLE_DEPENDENCY_REASONS as readonly string[]).includes(reason)
94
+ ? "non-overridable-security-error"
95
+ : "overridable-quality-error";
96
+ }
97
+
98
+ export function isOverridableImageDependencyReason(reason: ImageDependencyReason): boolean {
99
+ return classifyImageFindingPublication(reason) === "overridable-quality-error";
100
+ }
101
+
102
+ /** Capture-time findings are never overridable in either classifier. */
103
+ export function classifyCaptureFindingApplication(
104
+ _reason: NonOverridableCaptureReason,
105
+ ): "non-overridable-security-error" {
106
+ return "non-overridable-security-error";
107
+ }
108
+
109
+ export function classifyCaptureFindingPublication(
110
+ _reason: NonOverridableCaptureReason,
111
+ ): "non-overridable-security-error" {
112
+ return "non-overridable-security-error";
113
+ }
114
+
115
+ /**
116
+ * Canonical sorted digest of an image finding set. Identity is id+code+reason only —
117
+ * never a locator, tenant, path, or byte digest.
118
+ */
119
+ export async function digestImageFindingSet(
120
+ findings: readonly ImageFindingIdentity[],
121
+ ): Promise<string> {
122
+ const lines = findings
123
+ .map((f) => `${f.id}\0${f.code}\0${f.reason}`)
124
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
125
+ return `sha256:${await sha256Hex(lines.join("\n"))}`;
126
+ }
127
+
128
+ /** Digest of the exact finding id strings recorded on an acceptance / problem. */
129
+ export async function digestFindingIdSet(findingIds: readonly string[]): Promise<string> {
130
+ const lines = [...findingIds].map(String).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
131
+ return `sha256:${await sha256Hex(lines.join("\n"))}`;
132
+ }
133
+
134
+ export { digestImageDependencyIdentity } from "./image-dependency-identity.js";
135
+
136
+ /**
137
+ * Capture-manifest digest: SHA-256 over the same sorted JSON encoding the capture
138
+ * store seals. Locators stay inside the sealed bytes; callers persist only this digest.
139
+ */
140
+ export async function digestCaptureManifest(manifest: {
141
+ version: 1;
142
+ revisionId: string;
143
+ entries: ReadonlyArray<{
144
+ provenance: { kind: "local" | "remote"; locator: string };
145
+ address: string;
146
+ byteLength: number;
147
+ mediaType: string;
148
+ }>;
149
+ }): Promise<string> {
150
+ const entries = [...manifest.entries].sort((left, right) => {
151
+ if (left.provenance.locator < right.provenance.locator) return -1;
152
+ if (left.provenance.locator > right.provenance.locator) return 1;
153
+ return left.address < right.address ? -1 : left.address > right.address ? 1 : 0;
154
+ });
155
+ const encoded = new TextEncoder().encode(
156
+ JSON.stringify({ version: 1, revisionId: manifest.revisionId, entries }),
157
+ );
158
+ return `sha256:${await sha256Hex(encoded)}`;
159
+ }
160
+
161
+ export function isImageFindingId(findingId: string): boolean {
162
+ const prefix = String(findingId).split(":", 1)[0]!.trim();
163
+ return prefix === "IMAGE" || prefix.startsWith("image-");
164
+ }
165
+
166
+ export function imageFindingIdsIn(findingIds: readonly string[]): string[] {
167
+ return findingIds.filter(isImageFindingId);
168
+ }
@@ -0,0 +1,31 @@
1
+ import type { ImageDependency, ImageDependencyFinding, ImageDependencyScan } from "./image-dependencies.js";
2
+
3
+ /** Recover one unresolved finding's exact scanner group without a locator in its ID. */
4
+ export function dependenciesForScanFinding(
5
+ finding: ImageDependencyFinding,
6
+ scan: ImageDependencyScan,
7
+ ): ImageDependency[] {
8
+ const match = /^image-dependency-(\d+)-(\d+)$/.exec(finding.id);
9
+ if (!match) return [];
10
+ const artifactIndex = Number(match[1]) - 1;
11
+ const groupOrdinal = Number(match[2]);
12
+ const seen = new Map<string, { dep: ImageDependency; ordinal: number }>();
13
+ const counts = new Map<number, number>();
14
+ for (const dep of scan.dependencies) {
15
+ if (dep.disposition !== "capture-required" && dep.disposition !== "security-error" &&
16
+ dep.disposition !== "deferred") continue;
17
+ const key = `${dep.artifactIndex}\0${dep.surface}\0${dep.disposition}\0${dep.reason}\0${dep.canonicalLocator}`;
18
+ if (seen.has(key)) continue;
19
+ const ordinal = (counts.get(dep.artifactIndex) ?? 0) + 1;
20
+ counts.set(dep.artifactIndex, ordinal);
21
+ seen.set(key, { dep, ordinal });
22
+ }
23
+ const group = [...seen.values()].find(
24
+ (entry) => entry.dep.artifactIndex === artifactIndex && entry.ordinal === groupOrdinal,
25
+ );
26
+ if (!group) return [];
27
+ return scan.dependencies.filter((dep) =>
28
+ dep.artifactIndex === group.dep.artifactIndex && dep.surface === group.dep.surface &&
29
+ dep.disposition === group.dep.disposition && dep.reason === group.dep.reason &&
30
+ dep.canonicalLocator === group.dep.canonicalLocator);
31
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Revision-bound overlay of capture-rewritten preview artifacts (read path).
3
+ *
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.
7
+ */
8
+ import { contentArtifactPath, customizationArtifactKey, themeArtifactPath } from "./customization-runtime.js";
9
+
10
+ export type ImagePreviewRewriteOverlay = {
11
+ version: 1;
12
+ revisionId: string;
13
+ /** Capture-manifest digest bound at write time; mismatched rewrite loses CAS. */
14
+ captureManifestDigest: string;
15
+ /** Full artifact path → content hash (same keys as SiteVersionManifest.artifacts). */
16
+ artifacts: Record<string, string>;
17
+ };
18
+
19
+ export function imagePreviewRewriteOverlayKey(tenantId: string, revisionId: string): string {
20
+ return `cust:image-preview-rewrite:${tenantId}:${revisionId}`;
21
+ }
22
+
23
+ /** Map a repo-relative capture artifact path to the preview manifest artifact path. */
24
+ export function previewManifestPathFor(tenantId: string, repoPath: string): string {
25
+ if (repoPath === "theme.json") return themeArtifactPath(tenantId);
26
+ if (repoPath.startsWith("content/")) {
27
+ return contentArtifactPath(tenantId, repoPath.slice("content/".length));
28
+ }
29
+ return `tenants/${tenantId}/${repoPath}`;
30
+ }
31
+
32
+ export async function readImagePreviewRewriteOverlay(
33
+ get: (key: string) => Promise<string | null>,
34
+ tenantId: string,
35
+ revisionId: string,
36
+ ): Promise<ImagePreviewRewriteOverlay | null> {
37
+ try {
38
+ const raw = await get(imagePreviewRewriteOverlayKey(tenantId, revisionId));
39
+ if (!raw) return null;
40
+ const parsed = JSON.parse(raw) as Partial<ImagePreviewRewriteOverlay>;
41
+ if (parsed.version !== 1 || parsed.revisionId !== revisionId) return null;
42
+ if (typeof parsed.captureManifestDigest !== "string") return null;
43
+ if (!/^sha256:[0-9a-f]{64}$/.test(parsed.captureManifestDigest)) return null;
44
+ if (!parsed.artifacts || typeof parsed.artifacts !== "object") return null;
45
+ return {
46
+ version: 1,
47
+ revisionId,
48
+ captureManifestDigest: parsed.captureManifestDigest,
49
+ artifacts: { ...parsed.artifacts },
50
+ };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /** Prefer overlay bytes for a full artifact path when present. */
57
+ export async function readWithImagePreviewRewriteOverlay(
58
+ get: (key: string) => Promise<string | null>,
59
+ tenantId: string,
60
+ revisionId: string,
61
+ manifestPath: string,
62
+ fallback: () => Promise<string | null>,
63
+ ): Promise<string | null> {
64
+ const overlay = await readImagePreviewRewriteOverlay(get, tenantId, revisionId);
65
+ const hash = overlay?.artifacts?.[manifestPath];
66
+ if (!hash) return fallback();
67
+ const bytes = await get(customizationArtifactKey(tenantId, hash));
68
+ return bytes ?? fallback();
69
+ }
@@ -63,6 +63,12 @@ export interface R2PutOptionsLike {
63
63
  * verify can detect a truncated/corrupted object independent of trusting the
64
64
  * store's own reported size alone (see `verifyMaterializedAssets`). */
65
65
  customMetadata?: Record<string, string>;
66
+ /**
67
+ * Conditional write. `etagDoesNotMatch: "*"` creates the object only when the
68
+ * key is absent (HTTP If-None-Match: *). Capture uses it so an immutable
69
+ * content address is never overwritten.
70
+ */
71
+ onlyIf?: { etagDoesNotMatch?: string };
66
72
  }
67
73
 
68
74
  export interface R2BucketLike {
@@ -2,14 +2,13 @@
2
2
  /**
3
3
  * Materialize a Gitea-hosted tenant's content into the monorepo working tree.
4
4
  *
5
- * Real tenants live in per-tenant Gitea repos (git.tokenoftrust.com/storefront/
6
- * <repo>) — the SOURCE OF TRUTH. The monorepo's `tenants/<id>/` dirs for those
7
- * tenants are gitignored, disposable LOCAL/PER-TENANT build input (the same
8
- * stance as apps/storefront/public/tenants/, see .gitignore). The shared
9
- * Storefront Worker reads published tenant revisions from KV/R2 and does NOT run
10
- * this first. Local tenant development and a named per-tenant static publish do.
11
- * Committed-fixture tenants (home, sample-store.e2e.test) are NOT materialized
12
- * — they're tracked in git and this script refuses to touch them.
5
+ * Gitea repos (git.tokenoftrust.com/storefront/<repo>) are the source of truth
6
+ * for tenants declared in the manifest. Materialized content uses the shared
7
+ * tenant directory resolver: `tenants/<id>/` for real tenants and
8
+ * `tenants/e2e/<id>/` for synthetic tenants. The shared Storefront Worker reads
9
+ * published tenant revisions from KV/R2; local development and per-tenant
10
+ * static publication materialize their exact source. Tenants absent from the
11
+ * manifest are committed fixtures and are refused.
13
12
  *
14
13
  * Which tenants are Gitea-classified lives in `tenants/.gitea-tenants.json`
15
14
  * (the same manifest the CI reintroduction guard reads —
@@ -24,9 +23,9 @@
24
23
  * the canonical shape (theme.json, content/, public/) when absent.
25
24
  * 3. Copy each mapped workspace path into the monorepo target, REPLACING the
26
25
  * target tree (stale files from a previous materialize are removed). Every
27
- * target must resolve under `tenants/<id>/` — anything else is refused.
26
+ * target must resolve under that tenant's directory — anything else is refused.
28
27
  * 4. Copy `.tot/config.json` itself (the CLI + validate flows read it) and
29
- * stamp `tenants/<id>/.materialized.json` with { repo, ref, sha, at } so
28
+ * stamp the tenant's `.materialized.json` with { repo, ref, sha, at } so
30
29
  * `--check` and humans can see what's populated.
31
30
  *
32
31
  * Modes:
@@ -71,6 +70,7 @@ import { execFileSync } from "node:child_process";
71
70
  import { tmpdir } from "node:os";
72
71
  import { dirname, join, resolve, sep } from "node:path";
73
72
  import { fileURLToPath, pathToFileURL } from "node:url";
73
+ import { tenantDirRelative } from "../lib/tenant-dirs.mjs";
74
74
 
75
75
  const here = dirname(fileURLToPath(import.meta.url));
76
76
  const repoRoot = resolve(here, "..", "..");
@@ -119,9 +119,14 @@ export function buildCloneUrl({ base, repo, user, token }) {
119
119
  * write elsewhere in the tree (compare reconcile's manifest deny-by-default).
120
120
  */
121
121
  export function resolveTargetPath(root, tenant, repoPath) {
122
- const target = resolve(root, repoPath);
123
- const bound = resolve(root, "tenants", tenant) + sep;
124
- if (!target.startsWith(bound) && target !== resolve(root, "tenants", tenant)) {
122
+ const declared = `tenants/${tenant}`;
123
+ const physical = tenantDirRelative(tenant);
124
+ const translated = repoPath === declared ? physical
125
+ : repoPath.startsWith(`${declared}/`) ? `${physical}${repoPath.slice(declared.length)}` : repoPath;
126
+ const target = resolve(root, translated);
127
+ const tenantRoot = resolve(root, physical);
128
+ const bound = tenantRoot + sep;
129
+ if (!target.startsWith(bound) && target !== tenantRoot) {
125
130
  throw new Error(
126
131
  `mapping target "${repoPath}" escapes tenants/${tenant}/ — refusing`,
127
132
  );
@@ -142,15 +147,19 @@ function canonicalMappings(tenant) {
142
147
  /**
143
148
  * Copy one mapping from the checked-out tenant repo into the monorepo tree.
144
149
  * `tree` targets are replaced wholesale so a removed file doesn't linger from a
145
- * previous materialize. Missing OPTIONAL workspace paths are skipped (a tenant
146
- * without public/ is fine); the reconcile-side required-artifact rules (theme +
150
+ * previous materialize. Missing OPTIONAL workspace paths remove any old target
151
+ * (a tenant without public/ is fine); the reconcile-side required-artifact rules (theme +
147
152
  * home present) are enforced by validate/reconcile, not here.
148
153
  */
149
154
  export function copyMapping({ checkoutDir, root, tenant, mapping, log = (/** @type {string} */ _m) => {} }) {
150
155
  const src = join(checkoutDir, mapping.workspace.replace(/\/+$/, ""));
151
156
  const target = resolveTargetPath(root, tenant, mapping.repo.replace(/\/+$/, ""));
157
+ if (target === resolve(root, tenantDirRelative(tenant))) {
158
+ throw new Error("mapping target cannot replace the tenant root");
159
+ }
152
160
  if (!existsSync(src)) {
153
- log(` ~ ${mapping.workspace} absent in repo — skipped`);
161
+ rmSync(target, { recursive: true, force: true });
162
+ log(` ~ ${mapping.workspace} absent in repo — old target removed`);
154
163
  return { copied: false, target };
155
164
  }
156
165
  if (mapping.kind === "tree" && existsSync(target)) {
@@ -229,7 +238,7 @@ export function materializeTenant({
229
238
  // local iteration against the default branch is legitimate; publishes are
230
239
  // separately guarded by ci-www-publish.sh's PUBLISH_REF requirement.
231
240
  console.warn(
232
- `[materialize-tenant-content] WARNING: no --ref given — materializing ${tenant} from the ` +
241
+ `[materialize-tenant-content] WARNING: no --ref given — materializing from the ` +
233
242
  `repo's DEFAULT branch, which may be stale. Pin a commit (--ref <sha|branch>) for anything ` +
234
243
  `feeding a build or publish.`,
235
244
  );
@@ -262,6 +271,8 @@ export function materializeTenant({
262
271
  if (existsSync(cfgPath)) {
263
272
  const cfgTarget = resolveTargetPath(root, tenant, `tenants/${tenant}/.tot/config.json`);
264
273
  cpSync(cfgPath, cfgTarget);
274
+ } else {
275
+ rmSync(resolveTargetPath(root, tenant, `tenants/${tenant}/.tot/config.json`), { force: true });
265
276
  }
266
277
  // The assets-r2-versioned opt-in marker:
267
278
  // copy-tenant-assets.mjs reads this from the MATERIALIZED tree, not the repo
@@ -276,6 +287,8 @@ export function materializeTenant({
276
287
  );
277
288
  cpSync(versionedMarkerSrc, versionedMarkerTarget);
278
289
  log(` + .tot/assets-versioned → tenants/${tenant}/.tot/assets-versioned`);
290
+ } else {
291
+ rmSync(resolveTargetPath(root, tenant, `tenants/${tenant}/.tot/assets-versioned`), { force: true });
279
292
  }
280
293
  const stamp = resolveTargetPath(root, tenant, `tenants/${tenant}/.materialized.json`);
281
294
  writeFileSync(
@@ -302,7 +315,7 @@ export function checkMaterialized(root = repoRoot) {
302
315
  const manifest = loadManifest(root);
303
316
  const missing = [];
304
317
  for (const tenant of Object.keys(manifest.tenants)) {
305
- const dir = join(root, "tenants", tenant);
318
+ const dir = join(root, tenantDirRelative(tenant));
306
319
  const populated =
307
320
  existsSync(join(dir, "theme.json")) ||
308
321
  existsSync(join(dir, "content")) ||
@@ -345,7 +358,7 @@ export function checkPinned(
345
358
  const scope = onlyTenants ? new Set(onlyTenants) : null;
346
359
  for (const tenant of Object.keys(manifest.tenants)) {
347
360
  if (scope && !scope.has(tenant)) continue;
348
- const dir = join(root, "tenants", tenant);
361
+ const dir = join(root, tenantDirRelative(tenant));
349
362
  const populated =
350
363
  existsSync(join(dir, "theme.json")) ||
351
364
  existsSync(join(dir, "content")) ||
@@ -432,7 +445,7 @@ export function checkFresh(
432
445
  const scope = onlyTenants ? new Set(onlyTenants) : null;
433
446
  for (const tenant of Object.keys(manifest.tenants)) {
434
447
  if (scope && !scope.has(tenant)) continue;
435
- const stampPath = join(root, "tenants", tenant, ".materialized.json");
448
+ const stampPath = join(root, tenantDirRelative(tenant), ".materialized.json");
436
449
  if (!existsSync(stampPath)) continue; // checkPinned already reports absent/missing
437
450
  let stamp;
438
451
  try {
@@ -610,15 +623,21 @@ function main() {
610
623
  }
611
624
  for (const tenant of targets) {
612
625
  const repo = manifest.tenants[tenant]?.repo ?? tenant;
613
- console.log(`Materializing ${tenant} (repo ${repo})…`);
614
- materializeTenant({
615
- tenant,
616
- repo,
617
- ref: args.ref,
618
- remote: args.remote,
619
- gitBase,
620
- log: (s) => console.log(s),
621
- });
626
+ const synthetic = manifest.tenants[tenant]?.kind === "synthetic-qa";
627
+ console.log(synthetic ? "Materializing synthetic QA source…" : `Materializing ${tenant} (repo ${repo})…`);
628
+ try {
629
+ materializeTenant({
630
+ tenant,
631
+ repo,
632
+ ref: args.ref,
633
+ remote: args.remote,
634
+ gitBase,
635
+ log: synthetic ? () => {} : (s) => console.log(s),
636
+ });
637
+ } catch (error) {
638
+ if (synthetic) throw new Error("synthetic QA materialization refused");
639
+ throw error;
640
+ }
622
641
  }
623
642
  }
624
643