@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.
- package/apps/storefront/src/lib/access/rate/kvWindow.ts +37 -43
- package/apps/storefront/src/pages/api/blog/[collection]/[slug]/react.ts +10 -13
- package/apps/storefront/src/pages/api/contact.ts +2 -2
- package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +30 -27
- package/apps/storefront/src/pages/api/newsletter.ts +17 -9
- package/package.json +1 -1
- package/packages/public-runtime/src/image-dependencies.ts +16 -5
- package/packages/public-runtime/src/image-release-gate.ts +62 -4
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fixed-window KV rate limiter
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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<
|
|
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-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
* -
|
|
13
|
-
*
|
|
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
|
|
101
|
+
const limit = await checkRateLimit(
|
|
103
102
|
kv,
|
|
104
|
-
`newsletter-subscribe:${tenantId}:${
|
|
103
|
+
`newsletter-subscribe:${tenantId}:${rateKey}`,
|
|
105
104
|
{ windowSeconds: RATE_WINDOW_SECONDS, maxRequests: RATE_MAX_PER_WINDOW },
|
|
106
105
|
);
|
|
107
|
-
if (!allowed)
|
|
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
|
-
|
|
112
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
129
|
-
|
|
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,
|
|
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, "
|
|
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.
|
|
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
|
-
|
|
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
|
-
):
|
|
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
|
-
):
|
|
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
|
|