@tokenoftrust/storefront-runner 4.8.1 → 4.9.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "4.8.1",
3
+ "version": "4.9.0",
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",
@@ -83,6 +83,12 @@ export interface ImageDependency {
83
83
  surface: ImageReferenceSurface;
84
84
  disposition: ImageDependencyDisposition;
85
85
  reason?: ImageDependencyReason;
86
+ /**
87
+ * Set when the reference is an absolute URL on one of the tenant's own origins: the path of the
88
+ * artifact that holds it. Such a URL is served by the tenant's origin or by nothing, so a gate can
89
+ * name the file and ask whether a repo file backs it (`tenant-origin-images.ts`).
90
+ */
91
+ tenantOriginIn?: string;
86
92
  }
87
93
 
88
94
  export interface ImageDependencyFinding {
@@ -1214,11 +1220,15 @@ export function classifyImageDependency(
1214
1220
  tenantOrigins?: readonly string[];
1215
1221
  },
1216
1222
  ): Pick<ImageDependency, "canonicalLocator" | "disposition" | "reason"> {
1217
- return classifyImageDependencyWithPrepared(
1223
+ const { tenantOrigin: _tenantOrigin, ...classified } = classifyImageDependencyWithPrepared(
1218
1224
  locator, context, preparePlatformHostedConfig(context.platformHosted), prepareTenantOrigins(context.tenantOrigins ?? []),
1219
1225
  );
1226
+ return classified;
1220
1227
  }
1221
1228
 
1229
+ /** A classification, and whether it came from an absolute URL on one of the tenant's own origins. */
1230
+ type Classified = Pick<ImageDependency, "canonicalLocator" | "disposition" | "reason"> & { tenantOrigin?: true };
1231
+
1222
1232
  function prepareTenantOrigins(tenantOrigins: readonly string[]): ReadonlySet<string> {
1223
1233
  return new Set(tenantOrigins.map(normalizedOrigin).filter((origin): origin is string => origin != null));
1224
1234
  }
@@ -1228,7 +1238,7 @@ function classifyParsedHttpUrl(
1228
1238
  context: { tenantId: string; artifactPath: string },
1229
1239
  platformHosted: PreparedPlatformHostedImageConfig,
1230
1240
  tenantOrigins: ReadonlySet<string>,
1231
- ): Pick<ImageDependency, "canonicalLocator" | "disposition" | "reason"> {
1241
+ ): Classified {
1232
1242
  if (!absolute.hostname || absolute.username || absolute.password) {
1233
1243
  return { canonicalLocator: "invalid:", disposition: "security-error", reason: "malformed" };
1234
1244
  }
@@ -1240,7 +1250,7 @@ function classifyParsedHttpUrl(
1240
1250
  // instead of the third-party fallback below. The pathname.startsWith("//") exclusion
1241
1251
  // guards a URL like https://tenant.example//evil.example/x.png, whose pathname would
1242
1252
  // otherwise re-parse as scheme-relative to a DIFFERENT origin on the recursive call.
1243
- return classifyImageDependencyWithPrepared(`${absolute.pathname}${absolute.search}`, context, platformHosted, tenantOrigins);
1253
+ return { ...classifyImageDependencyWithPrepared(`${absolute.pathname}${absolute.search}`, context, platformHosted, tenantOrigins), tenantOrigin: true };
1244
1254
  }
1245
1255
  absolute.hash = "";
1246
1256
  const canonical = absolute.toString();
@@ -1259,7 +1269,7 @@ function classifyImageDependencyWithPrepared(
1259
1269
  context: { tenantId: string; artifactPath: string },
1260
1270
  platformHosted: PreparedPlatformHostedImageConfig,
1261
1271
  tenantOrigins: ReadonlySet<string>,
1262
- ): Pick<ImageDependency, "canonicalLocator" | "disposition" | "reason"> {
1272
+ ): Classified {
1263
1273
  const raw = normalizeUrlInput(locator);
1264
1274
  if (!raw || CONTROL_RE.test(raw)) {
1265
1275
  return { canonicalLocator: "invalid:", disposition: "security-error", reason: "malformed" };
@@ -1485,7 +1495,7 @@ export function scanImageDependencies(
1485
1495
  });
1486
1496
  continue;
1487
1497
  }
1488
- const classified = classifyImageDependencyWithPrepared(ref.classificationLocator ?? ref.locator, {
1498
+ const { tenantOrigin, ...classified } = classifyImageDependencyWithPrepared(ref.classificationLocator ?? ref.locator, {
1489
1499
  tenantId: options.tenantId,
1490
1500
  artifactPath: artifact.path,
1491
1501
  }, platformHosted, tenantOrigins);
@@ -1495,6 +1505,7 @@ export function scanImageDependencies(
1495
1505
  occurrence,
1496
1506
  surface: ref.surface,
1497
1507
  ...classified,
1508
+ ...(tenantOrigin ? { tenantOriginIn: artifact.path } : {}),
1498
1509
  });
1499
1510
  }
1500
1511
  }
@@ -31,15 +31,45 @@ import type { ImageDependency, ImageDependencyArtifact } from "./image-dependenc
31
31
  /**
32
32
  * `unregistered` and `unsupported-format` are integrity: nothing may publish through them.
33
33
  * `unresolved` and `mutable` judge the tenant's own references: an owner may accept them exactly.
34
+ * `tenant-origin-missing` is not acceptable: an absolute URL on the tenant's own domain that no
35
+ * file in the release backs is served by nothing once that domain points at the platform, and
36
+ * capture has no source to fetch it from (see {@link tenantOriginImageGaps}).
34
37
  */
35
- export type ReleaseImageFindingCode = "unregistered" | "unsupported-format" | "unresolved" | "mutable";
38
+ export type ReleaseImageFindingCode = "unregistered" | "unsupported-format" | "unresolved" | "mutable" | "tenant-origin-missing";
36
39
 
37
- export const BLOCKING_RELEASE_IMAGE_CODES: readonly ReleaseImageFindingCode[] = Object.freeze(["unregistered", "unsupported-format"]);
40
+ export const BLOCKING_RELEASE_IMAGE_CODES: readonly ReleaseImageFindingCode[] = Object.freeze(["unregistered", "unsupported-format", "tenant-origin-missing"]);
38
41
 
39
42
  export interface ReleaseImageFinding {
40
43
  code: ReleaseImageFindingCode;
41
44
  /** The reference as rendered, query and fragment stripped. */
42
45
  ref: string;
46
+ /** `tenant-origin-missing` only: the files that hold the reference, sorted. */
47
+ files?: string[];
48
+ }
49
+
50
+ /**
51
+ * The tenant-origin image references no file backs. A reference is an absolute URL on one of the
52
+ * tenant's own origins (`ImageDependency.tenantOriginIn`) that scanning classified as a repo-local
53
+ * path to capture; `backed` says whether a file at that path exists where the caller looks (the
54
+ * revision's repo files before capture, the release's files at publish). One entry per reference,
55
+ * naming the files that hold it.
56
+ */
57
+ export function tenantOriginImageGaps(
58
+ dependencies: readonly ImageDependency[],
59
+ backed: (path: string) => boolean,
60
+ ): Array<{ ref: string; files: string[] }> {
61
+ const holders = new Map<string, Set<string>>();
62
+ for (const dependency of dependencies) {
63
+ if (dependency.tenantOriginIn === undefined || dependency.disposition !== "capture-required" || dependency.reason !== "local") continue;
64
+ const ref = refOf(dependency);
65
+ if (!ref.startsWith("/") || ref.startsWith("//")) continue;
66
+ if (backed(ref)) continue;
67
+ if (!holders.has(ref)) holders.set(ref, new Set());
68
+ holders.get(ref)!.add(dependency.tenantOriginIn);
69
+ }
70
+ return [...holders.entries()]
71
+ .map(([ref, files]) => ({ ref, files: [...files].sort() }))
72
+ .sort((a, b) => (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
43
73
  }
44
74
 
45
75
  export type BundlePathResolution =
@@ -71,7 +101,7 @@ export interface ReleaseImageFacts {
71
101
  export interface LegacyImageAllowance {
72
102
  tenant: string;
73
103
  ref: string;
74
- code: Exclude<ReleaseImageFindingCode, "unsupported-format">;
104
+ code: Exclude<ReleaseImageFindingCode, "unsupported-format" | "tenant-origin-missing">;
75
105
  /** Who answers for this entry and removes it by its exit date. */
76
106
  owner: string;
77
107
  reason: string;
@@ -171,8 +201,16 @@ export function judgeReleaseImages(input: {
171
201
  ...input.dependencies.map((dependency) => ({ dependency, structured: false })),
172
202
  ...(input.structuredDependencies ?? []).map((dependency) => ({ dependency, structured: true })),
173
203
  ];
204
+ // A tenant-origin reference no release file backs is its own finding, whichever surface renders it.
205
+ const gaps = tenantOriginImageGaps(tagged.map(({ dependency }) => dependency), (path) => {
206
+ const rel = bundleRelPath(path);
207
+ return rel !== null && input.facts.bundlePath(rel).kind !== "absent";
208
+ });
209
+ const gapRefs = new Set(gaps.map((gap) => gap.ref));
210
+ for (const gap of gaps) raw.set(`tenant-origin-missing\0${gap.ref}`, { code: "tenant-origin-missing", ref: gap.ref, files: gap.files });
174
211
  for (const { dependency, structured } of tagged) {
175
212
  const ref = refOf(dependency);
213
+ if (dependency.tenantOriginIn !== undefined && gapRefs.has(ref)) continue;
176
214
  const key = `${structured}\0${dependency.disposition}\0${dependency.reason ?? ""}\0${ref}`;
177
215
  if (seen.has(key)) continue;
178
216
  seen.add(key);
@@ -195,7 +233,7 @@ export function judgeReleaseImages(input: {
195
233
  const allowed: ReleaseImageFinding[] = [];
196
234
  const expired: LegacyImageAllowance[] = [];
197
235
  for (const finding of [...raw.values()].sort(compareFindings)) {
198
- const allowance = finding.code === "unsupported-format" ? undefined : allowanceFor(input.allowances ?? [], input.tenantId, finding);
236
+ const allowance = finding.code === "unsupported-format" || finding.code === "tenant-origin-missing" ? undefined : allowanceFor(input.allowances ?? [], input.tenantId, finding);
199
237
  if (allowance && Date.parse(allowance.exit) > now) allowed.push(finding);
200
238
  else {
201
239
  if (allowance) expired.push(allowance);
@@ -215,6 +253,7 @@ export const RELEASE_IMAGE_FINDING_PREFIX: Readonly<Record<ReleaseImageFindingCo
215
253
  "unsupported-format": "IMAGE_FORMAT",
216
254
  unresolved: "IMAGE_UNRESOLVED",
217
255
  mutable: "IMAGE_MUTABLE",
256
+ "tenant-origin-missing": "IMAGE_TENANT_ORIGIN",
218
257
  });
219
258
 
220
259
  /**
@@ -237,9 +276,28 @@ export function releaseImageFindingText(finding: ReleaseImageFinding): string {
237
276
  case "mutable":
238
277
  return `${prefix}: ${finding.ref} is a release-local path whose bytes are not pinned to a pool version, so the image ` +
239
278
  "it serves can change under the same name — add it with `tot image add <file>` and reference the URL it prints.";
279
+ case "tenant-origin-missing":
280
+ return tenantOriginImageText(finding.ref, finding.files ?? [], prefix);
240
281
  }
241
282
  }
242
283
 
284
+ const NAMED_FILES = 5;
285
+
286
+ /**
287
+ * The text for a tenant-origin image no file backs: where it is referenced, what path it names, why
288
+ * that breaks, and the two remedies. Shared by the publish gates and the preview capture pass, so the
289
+ * remedy reads the same wherever the owner meets it.
290
+ */
291
+ export function tenantOriginImageText(ref: string, files: readonly string[], prefix = "IMAGE_TENANT_ORIGIN"): string {
292
+ const shown = files.slice(0, NAMED_FILES).join(", ");
293
+ const more = files.length > NAMED_FILES ? ` and ${files.length - NAMED_FILES} more` : "";
294
+ return `${prefix}: ${ref} is referenced as a URL on this store's own domain${shown ? ` in ${shown}${more}` : ""}, but no file in the ` +
295
+ "repository serves that path, so the image breaks when the domain points at the platform and capture has no source to fetch it from — " +
296
+ "reference the image at a source URL that is not this store's domain (a Shopify store: " +
297
+ "`node agency-kit/tools/normalize-shopify-image-urls.mjs --store <checkout>` rewrites /cdn/shop/ references to the shop CDN URL), " +
298
+ "or add the image with `tot image add <file>` and reference the URL it prints.";
299
+ }
300
+
243
301
  const ALLOWANCE_CODES = new Set(["unregistered", "unresolved", "mutable"]);
244
302
  const DATE = /^\d{4}-\d{2}-\d{2}$/;
245
303