@tokenoftrust/storefront-runner 4.9.0 → 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
|
@@ -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.9.
|
|
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",
|