@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.
@@ -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.9.0",
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",