@tokenoftrust/storefront-runner 4.8.1 → 4.9.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,16 +1,23 @@
1
1
  /**
2
- * Fixed-window KV rate limiter — the storefront's first throttle, built small
3
- * and generic so any public POST can share it (reactions today; blog comments
4
- * and other reader writes next).
2
+ * Fixed-window KV rate limiter for public POSTs.
5
3
  *
6
4
  * A request is counted into the bucket for the current fixed window
7
5
  * (`floor(now / windowSeconds)`), keyed `<key>:<bucket>`. The counter TTLs out a
8
6
  * window after it is first written, so old buckets self-expire — no sweep.
9
7
  *
10
- * Best-effort by construction: a KV read/write hiccup FAILS OPEN (returns
11
- * `allowed: true`) and never throws. A rate limiter must never turn a KV blip
12
- * into a rejected legitimate request; the abuse it guards against is low-stakes
13
- * (mirrors the "never throws" discipline of `blog/viewCounts.ts`).
8
+ * Two entry points, and the difference is the decision a caller makes:
9
+ * - {@link checkRateLimit} FAILS CLOSED. No KV binding, or a KV read/write error,
10
+ * refuses with `reason: "unavailable"`. This is the default for every public
11
+ * write whose abuse is not trivial (a stored message, a subscriber row, a mail).
12
+ * - {@link checkRateLimitFailOpen} admits when KV is missing or erroring. Only for
13
+ * a write whose abuse blast radius is a counter and where refusing a legitimate
14
+ * request on a KV blip is the worse outcome; the call site names that choice.
15
+ *
16
+ * Keys carry a hashed client identifier, never a raw IP: build one with
17
+ * `requestClientIp` + `hashConsentIp`.
18
+ *
19
+ * Increment is a read-modify-write, so under concurrency a few increments can be
20
+ * lost to KV's last-write-wins — acceptable for a soft abuse throttle, not a hard quota.
14
21
  */
15
22
 
16
23
  export interface RateLimitOptions {
@@ -20,54 +27,26 @@ export interface RateLimitOptions {
20
27
  maxRequests: number;
21
28
  }
22
29
 
30
+ export type RateLimitVerdict =
31
+ | { allowed: true }
32
+ | { allowed: false; reason: "limited" | "unavailable" };
33
+
23
34
  /** The current fixed-window bucket index for `now` (ms epoch). */
24
35
  function windowBucket(windowSeconds: number, nowMs: number): number {
25
36
  return Math.floor(nowMs / 1000 / windowSeconds);
26
37
  }
27
38
 
28
39
  /**
29
- * Count one request against `key` and report whether it is within the cap for
30
- * the current window. Increment is a read-modify-write, so under concurrency a
31
- * few increments can be lost to KV's last-write-wins — acceptable for a soft
32
- * abuse throttle, not a hard quota. Fails OPEN on any KV error.
40
+ * Count one request against `key` and report whether it is within the cap for the
41
+ * current window. FAIL-CLOSED: no KV binding, or a KV read/write error, refuses
42
+ * with `reason: "unavailable"` instead of admitting.
33
43
  */
34
44
  export async function checkRateLimit(
35
45
  kv: KVNamespace | undefined,
36
46
  key: string,
37
47
  opts: RateLimitOptions,
38
48
  nowMs: number = Date.now(),
39
- ): Promise<{ allowed: boolean }> {
40
- if (!kv) return { allowed: true };
41
- try {
42
- const bucket = windowBucket(opts.windowSeconds, nowMs);
43
- const bucketKey = `${key}:${bucket}`;
44
- const raw = await kv.get(bucketKey);
45
- const current = raw == null ? 0 : Number.parseInt(raw, 10);
46
- const count = Number.isFinite(current) && current >= 0 ? current : 0;
47
- if (count >= opts.maxRequests) return { allowed: false };
48
- await kv.put(bucketKey, String(count + 1), { expirationTtl: opts.windowSeconds });
49
- return { allowed: true };
50
- } catch {
51
- return { allowed: true };
52
- }
53
- }
54
-
55
- export type ClosedRateLimitVerdict =
56
- | { allowed: true }
57
- | { allowed: false; reason: "limited" | "unavailable" };
58
-
59
- /**
60
- * The same fixed window, FAIL-CLOSED: no KV binding, or a KV read/write error,
61
- * refuses with `reason: "unavailable"` instead of admitting. For public writes
62
- * whose abuse is not low-stakes (a stored visitor message), where an unthrottled
63
- * window is worse than a refused request.
64
- */
65
- export async function checkRateLimitClosed(
66
- kv: KVNamespace | undefined,
67
- key: string,
68
- opts: RateLimitOptions,
69
- nowMs: number = Date.now(),
70
- ): Promise<ClosedRateLimitVerdict> {
49
+ ): Promise<RateLimitVerdict> {
71
50
  if (!kv) return { allowed: false, reason: "unavailable" };
72
51
  let count: number;
73
52
  const bucketKey = `${key}:${windowBucket(opts.windowSeconds, nowMs)}`;
@@ -86,3 +65,18 @@ export async function checkRateLimitClosed(
86
65
  }
87
66
  return { allowed: true };
88
67
  }
68
+
69
+ /**
70
+ * The same fixed window, FAIL-OPEN: no KV binding, or a KV error, returns
71
+ * `allowed: true` and never throws. A call site uses this only for a write whose
72
+ * abuse is low-stakes (a reaction counter), and says so where it calls.
73
+ */
74
+ export async function checkRateLimitFailOpen(
75
+ kv: KVNamespace | undefined,
76
+ key: string,
77
+ opts: RateLimitOptions,
78
+ nowMs: number = Date.now(),
79
+ ): Promise<{ allowed: boolean }> {
80
+ const verdict = await checkRateLimit(kv, key, opts, nowMs);
81
+ return { allowed: verdict.allowed || verdict.reason === "unavailable" };
82
+ }
@@ -5,21 +5,23 @@
5
5
  *
6
6
  * Discipline (mirrors the other public POSTs, `newsletter.ts` / `rum/vitals.ts`):
7
7
  * - Tenant is resolved from `locals.tenant`, NEVER the request body.
8
- * - A soft per-IP KV rate limit stops trivial scripted inflation; over-cap
8
+ * - A soft per-visitor KV rate limit (hashed client IP, fail-open) stops trivial scripted inflation; over-cap
9
9
  * returns the current tallies (429) without counting — abuse blast-radius is
10
10
  * ~0 (a counter), so the cap is lenient.
11
11
  * - Never 500. Every path returns a clean JSON response; `recordReaction` is
12
12
  * itself best-effort and never throws.
13
13
  */
14
14
  import type { APIRoute } from "astro";
15
- import { readKv } from "@/lib/publication/shared/env";
15
+ import { readEnv, readKv } from "@/lib/publication/shared/env";
16
+ import { requestClientIp } from "@/lib/publication/shared/cdnForward";
17
+ import { hashConsentIp } from "@/lib/engagement/newsletter/consent";
16
18
  import {
17
19
  getReactionCounts,
18
20
  isReaction,
19
21
  recordReaction,
20
22
  type Reaction,
21
23
  } from "@/lib/content/blog/reactions";
22
- import { checkRateLimit } from "@/lib/access/rate/kvWindow";
24
+ import { checkRateLimitFailOpen } from "@/lib/access/rate/kvWindow";
23
25
 
24
26
  export const prerender = false;
25
27
 
@@ -33,15 +35,6 @@ function json(body: unknown, status = 200): Response {
33
35
  });
34
36
  }
35
37
 
36
- /** Best-effort client IP for rate-limit keying — Cloudflare's header, else XFF. */
37
- function clientIp(request: Request): string {
38
- const cf = request.headers.get("cf-connecting-ip");
39
- if (cf) return cf;
40
- const xff = request.headers.get("x-forwarded-for");
41
- if (xff) return xff.split(",")[0]!.trim();
42
- return "unknown";
43
- }
44
-
45
38
  /** Pull the reaction from a JSON or form-encoded body. */
46
39
  async function readReaction(request: Request): Promise<string | null> {
47
40
  try {
@@ -80,7 +73,11 @@ export const POST: APIRoute = async (context) => {
80
73
  );
81
74
  }
82
75
 
83
- const { allowed } = await checkRateLimit(kv, `react:${tenantId}:${clientIp(context.request)}`, {
76
+ // Deliberately fail-open: a reaction is a counter, so a KV blip must not refuse a reader.
77
+ const cdnSecret = await readEnv("CDN_ORIGIN_SECRET");
78
+ const ip = await requestClientIp(context.request.headers, cdnSecret);
79
+ const ipKey = await hashConsentIp(ip, `react-rate:${cdnSecret ?? tenantId}`);
80
+ const { allowed } = await checkRateLimitFailOpen(kv, `react:${tenantId}:${ipKey}`, {
84
81
  windowSeconds: RATE_WINDOW_SECONDS,
85
82
  maxRequests: RATE_MAX_PER_WINDOW,
86
83
  });
@@ -13,7 +13,7 @@
13
13
  import type { APIContext } from "astro";
14
14
  import { readD1, readEnv, readKv } from "@/lib/publication/shared/env";
15
15
  import { fromD1 } from "@/lib/catalog/d1/catalog";
16
- import { checkRateLimitClosed } from "@/lib/access/rate/kvWindow";
16
+ import { checkRateLimit } from "@/lib/access/rate/kvWindow";
17
17
  import { hashConsentIp } from "@/lib/engagement/newsletter/consent";
18
18
  import { ContactMessagesStore } from "@/lib/engagement/contact/contactMessages";
19
19
  import { verifyContactToken } from "@/lib/engagement/contact/submitToken";
@@ -107,7 +107,7 @@ export async function POST(context: APIContext): Promise<Response> {
107
107
  const kv = await readKv("TENANT_CACHE");
108
108
  const ip = await requestClientIp(request.headers, await readEnv("CDN_ORIGIN_SECRET"));
109
109
  const ipKey = await hashConsentIp(ip, `contact-rate:${secret}`);
110
- const limit = await checkRateLimitClosed(kv, `contact:${tenantId}:${ipKey}`, {
110
+ const limit = await checkRateLimit(kv, `contact:${tenantId}:${ipKey}`, {
111
111
  windowSeconds: CONTACT_RATE_WINDOW_SECONDS,
112
112
  maxRequests: CONTACT_RATE_MAX_PER_WINDOW,
113
113
  });
@@ -5,19 +5,22 @@
5
5
  * Discipline (mirrors the other public POSTs, `blog/.../react.ts` / `newsletter.ts`):
6
6
  * - `tenant_id` is resolved from `locals.tenant`, `collection` from the ROUTE —
7
7
  * NEVER the request body (tenant is the isolation boundary).
8
- * - A soft per-IP KV rate limit (`kvWindow`) stops trivial scripted signups.
8
+ * - A per-visitor KV rate limit (`kvWindow`, keyed on the hashed client IP,
9
+ * fail-closed) stops scripted signups; a missing or failing KV refuses (503).
9
10
  * - `INSERT OR IGNORE` a `pending` row with a fresh opaque token + consent
10
11
  * record (`consent_source` = referrer, `consent_ip_hash` = sha256(ip+salt) —
11
12
  * NEVER a raw IP). A repeat subscribe is idempotent.
12
- * - Confirmation email is STUBBED (no ESP in-repo yet — N4 wires it); a stub
13
- * failure never fails the subscribe.
13
+ * - A confirmation email that cannot be sent, a missing apps DB and a failed
14
+ * write are errors (502/503 JSON), never a success: the visitor is not told to
15
+ * check an inbox that will stay empty. A retry re-sends while the row is pending.
14
16
  * - Never 500. A `fetch`/JSON caller gets a clean JSON body; a plain form POST
15
- * is redirected back to the referrer so the page can show its own thanks.
17
+ * that succeeded is redirected back to the referrer so the page can show its own thanks.
16
18
  */
17
19
  import type { APIRoute } from "astro";
18
20
  import { readD1, readKv, readEnv } from "@/lib/publication/shared/env";
19
21
  import { fromD1 } from "@/lib/catalog/d1/catalog";
20
22
  import { checkRateLimit } from "@/lib/access/rate/kvWindow";
23
+ import { requestClientIp } from "@/lib/publication/shared/cdnForward";
21
24
  import { NewsletterSubscribersStore } from "@/lib/engagement/newsletter/subscribers";
22
25
  import { randomToken, hashConsentIp } from "@/lib/engagement/newsletter/consent";
23
26
  import { sendConfirmationEmail } from "@/lib/engagement/newsletter/confirmationEmail";
@@ -38,15 +41,6 @@ function json(body: unknown, status = 200): Response {
38
41
  });
39
42
  }
40
43
 
41
- /** Best-effort client IP for rate-limit keying + consent record — CF header, else XFF. */
42
- function clientIp(request: Request): string {
43
- const cf = request.headers.get("cf-connecting-ip");
44
- if (cf) return cf;
45
- const xff = request.headers.get("x-forwarded-for");
46
- if (xff) return xff.split(",")[0]!.trim();
47
- return "unknown";
48
- }
49
-
50
44
  /** Read `email` from a JSON or form-encoded body; null if unparseable. */
51
45
  async function readEmail(request: Request): Promise<string | null> {
52
46
  try {
@@ -98,26 +92,31 @@ export const POST: APIRoute = async (context) => {
98
92
  return json({ ok: false, error: "invalid_email" }, 400);
99
93
  }
100
94
 
95
+ const salt = (await readEnv("NEWSLETTER_CONSENT_SALT")) ?? DEV_CONSENT_SALT;
96
+ const ip = await requestClientIp(context.request.headers, await readEnv("CDN_ORIGIN_SECRET"));
97
+ const consentIpHash = await hashConsentIp(ip, salt);
98
+ const rateKey = await hashConsentIp(ip, `newsletter-rate:${salt}`);
99
+
101
100
  const kv = await readKv("TENANT_CACHE");
102
- const { allowed } = await checkRateLimit(
101
+ const limit = await checkRateLimit(
103
102
  kv,
104
- `newsletter-subscribe:${tenantId}:${clientIp(context.request)}`,
103
+ `newsletter-subscribe:${tenantId}:${rateKey}`,
105
104
  { windowSeconds: RATE_WINDOW_SECONDS, maxRequests: RATE_MAX_PER_WINDOW },
106
105
  );
107
- if (!allowed) return json({ ok: false, error: "rate_limited" }, 429);
106
+ if (!limit.allowed) {
107
+ return limit.reason === "limited"
108
+ ? json({ ok: false, error: "rate_limited" }, 429)
109
+ : json({ ok: false, error: "unavailable" }, 503);
110
+ }
108
111
 
109
112
  const d1 = await readD1("STOREFRONT_APPS_DB");
110
113
  if (!d1) {
111
- // No apps DB binding (local/dev without D1): can't persist. Don't fail the
112
- // visitor — log and acknowledge so the flow is exercisable in dev.
113
- console.log(`[newsletter] subscribe not persisted (no D1) → ${tenantId}/${collection}`);
114
- return ok(context.request);
114
+ console.error(`[newsletter] subscribe refused: no apps DB binding tenant=${tenantId} collection=${collection}`);
115
+ return json({ ok: false, error: "unavailable" }, 503);
115
116
  }
116
117
 
117
118
  const referer = context.request.headers.get("referer");
118
119
  const consentSource = referer ? new URL(referer).pathname : null;
119
- const salt = (await readEnv("NEWSLETTER_CONSENT_SALT")) ?? DEV_CONSENT_SALT;
120
- const consentIpHash = await hashConsentIp(clientIp(context.request), salt);
121
120
 
122
121
  const store = new NewsletterSubscribersStore(fromD1(d1));
123
122
  const result = await store.subscribe({
@@ -130,25 +129,29 @@ export const POST: APIRoute = async (context) => {
130
129
  });
131
130
 
132
131
  // Only (re)send confirmation while still pending — never re-mail a confirmed
133
- // or unsubscribed address. Best-effort; a stub failure must not fail signup.
132
+ // or unsubscribed address. A failed send is an error: the row stays pending, so
133
+ // the visitor's retry re-sends.
134
134
  if (result.status === "pending") {
135
135
  const origin = new URL(context.request.url).origin;
136
136
  // The tenant's real routed prefix: a pinned preview's basePath has no API mirror.
137
137
  const apiBasePath = context.locals?.apiBasePath ?? context.locals?.basePath ?? "";
138
138
  const confirmUrl = `${origin}${apiBasePath}/api/newsletter/confirm?token=${result.token}`;
139
- await sendConfirmationEmail({ email, collection, confirmUrl }).catch((err: unknown) => {
139
+ try {
140
+ await sendConfirmationEmail({ email, collection, confirmUrl });
141
+ } catch (err) {
140
142
  console.error(
141
143
  `[newsletter] confirmation email failed tenant=${tenantId} collection=${collection}: ${err instanceof Error ? err.name : "unknown"}`,
142
144
  );
143
- });
145
+ return json({ ok: false, error: "confirmation_not_sent" }, 502);
146
+ }
144
147
  }
145
148
 
146
149
  return ok(context.request);
147
150
  } catch (err) {
148
- // Never 500 on a signup — acknowledge, but never swallow the failure silently.
151
+ // Never 500 on a signup, and never report a success that did not happen.
149
152
  console.error(
150
153
  `[newsletter] subscribe failed collection=${collection}: ${err instanceof Error ? err.name : "unknown"}`,
151
154
  );
152
- return ok(context.request);
155
+ return json({ ok: false, error: "unavailable" }, 503);
153
156
  }
154
157
  };
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * Every outcome is a confirmation page with a link back to where the visitor was;
14
14
  * a script that asks for JSON gets `{ ok, status }`. Robustness: a signup must NEVER
15
- * 500. All failures degrade to a friendly thank-you.
15
+ * 500, and a signup that was not recorded (no KV binding, a failed write) answers
16
+ * `failed` with a 503 rather than a thank-you.
16
17
  */
17
18
  import type { APIContext } from "astro";
18
19
  import { readKv } from "@/lib/publication/shared/env";
@@ -22,7 +23,7 @@ import { withBase } from "@/lib/publication/shared/basePath";
22
23
  // deliverability; the ESP validates properly downstream.
23
24
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
24
25
 
25
- export type NewsletterStatus = "subscribed" | "preview" | "invalid";
26
+ export type NewsletterStatus = "subscribed" | "preview" | "invalid" | "failed";
26
27
 
27
28
  const COPY: Record<NewsletterStatus, { heading: string; message: string }> = {
28
29
  subscribed: {
@@ -38,8 +39,15 @@ const COPY: Record<NewsletterStatus, { heading: string; message: string }> = {
38
39
  heading: "That didn't go through.",
39
40
  message: "That address didn't look quite right — head back and try again.",
40
41
  },
42
+ failed: {
43
+ heading: "That didn't go through.",
44
+ message: "We couldn't record your signup just now — head back and try again in a moment.",
45
+ },
41
46
  };
42
47
 
48
+ /** A failed recording is a 503; every other outcome is a 200 page or body. */
49
+ const HTTP_STATUS: Record<NewsletterStatus, number> = { subscribed: 200, preview: 200, invalid: 200, failed: 503 };
50
+
43
51
  function escapeHtml(value: string): string {
44
52
  return value.replace(/[&<>"']/g, (ch) => `&#${ch.charCodeAt(0)};`);
45
53
  }
@@ -65,8 +73,8 @@ function wantsJson(request: Request): boolean {
65
73
 
66
74
  function respond(context: APIContext, status: NewsletterStatus): Response {
67
75
  if (wantsJson(context.request)) {
68
- return new Response(JSON.stringify({ ok: status !== "invalid", status }), {
69
- status: 200,
76
+ return new Response(JSON.stringify({ ok: status !== "invalid" && status !== "failed", status }), {
77
+ status: HTTP_STATUS[status],
70
78
  headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
71
79
  });
72
80
  }
@@ -98,7 +106,7 @@ function respond(context: APIContext, status: NewsletterStatus): Response {
98
106
  </body>
99
107
  </html>`;
100
108
  return new Response(html, {
101
- status: 200,
109
+ status: HTTP_STATUS[status],
102
110
  headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
103
111
  });
104
112
  }
@@ -125,16 +133,16 @@ export async function POST(context: APIContext): Promise<Response> {
125
133
  JSON.stringify({ email, source, at: new Date().toISOString() }),
126
134
  );
127
135
  } else {
128
- // No KV binding (local/dev): nothing to persist to.
129
- console.log(`[newsletter] signup not persisted (no TENANT_CACHE) tenant=${tenantId} source=${source}`);
136
+ console.error(`[newsletter] signup not recorded: no TENANT_CACHE binding tenant=${tenantId}`);
137
+ return respond(context, "failed");
130
138
  }
131
139
  return respond(context, "subscribed");
132
140
  } catch (err) {
133
- // Never 500 on a signup, but never swallow the failure silently either.
141
+ // Never 500 on a signup, and never report a success that did not happen.
134
142
  console.error(
135
143
  `[newsletter] signup failed tenant=${context.locals.tenant?.tenant_id ?? "?"}: ${err instanceof Error ? err.name : "unknown"}`,
136
144
  );
137
- return respond(context, "subscribed");
145
+ return respond(context, "failed");
138
146
  }
139
147
  }
140
148
 
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.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",
@@ -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