create-ailk 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -1,13 +1,22 @@
1
1
  /**
2
2
  * Per-IP rate limit middleware for anonymous write routes.
3
3
  *
4
- * SINGLE-REPLICA CAVEAT: This module uses an in-process Map to store request
5
- * counts. Each Fastify instance maintains its own bucket state. In a
6
- * multi-replica deployment (e.g. 3 Vercel functions), an attacker can issue
7
- * up to N × perIpPerMinute requests per minute per IP, where N is the replica
8
- * count. For OSS v1 (single-replica), this is acceptable. A Redis-backed swap
9
- * is tracked in the follow-up issue "Phase 6+ — Redis-backed rate limit for
10
- * multi-replica deploys."
4
+ * SINGLE-REPLICA CAVEAT: the memory store below keeps an in-process Map of
5
+ * request counts. Each Fastify instance maintains its own bucket state. On a
6
+ * deployment with no database (or `AILK_RATE_LIMIT_STORE=memory`), an
7
+ * attacker can issue up to N × perIpPerMinute requests per minute per IP,
8
+ * where N is the replica/isolate count.
9
+ *
10
+ * SHARED STORE (#5040): on a deployment with a database, every registration
11
+ * defaults to counting in `rate_limit_counters` instead (D4), so one
12
+ * visitor's limit holds across every concurrent isolate. See
13
+ * `apps/api/src/lib/rate-limit-store.ts` for the store seam and
14
+ * `docs/specs/5040-the-api-s-rate-limiter-spec.md` D11 for the full list of
15
+ * what this protects against and what it does not — in short: it stops one
16
+ * identity from exceeding its ceiling by spreading requests over isolates,
17
+ * but a caller that mints a fresh identity per request (a forged
18
+ * `x-forwarded-for` the edge passes through unmodified) still gets a fresh
19
+ * bucket every time, in either store.
11
20
  *
12
21
  * Cleanup strategy: evict stale buckets on every 429 emission and every 100
13
22
  * allowed requests (bounded, O(n) over bucket count — negligible for typical
@@ -23,7 +32,9 @@
23
32
  * caps total entries independent of the time-based eviction: once at
24
33
  * capacity, a genuinely new identity evicts the single oldest-inserted entry
25
34
  * (`Map` iteration order is insertion order) before inserting itself — O(1)
26
- * amortized, no behavior change for any caller under the cap.
35
+ * amortized, no behavior change for any caller under the cap. The database
36
+ * store has no such cap: it relies entirely on the sweep (D6) to bound its
37
+ * row count.
27
38
  *
28
39
  * Trust-proxy boundary: this middleware reads req.ip, which Fastify populates
29
40
  * from the socket address and any trusted proxy headers (X-Forwarded-For) per
@@ -51,8 +62,10 @@
51
62
  * believes only alongside a hub-issued key in `x-ailk-visitor-forward-key`,
52
63
  * and only on the `FORWARDED_VISITOR_ROUTES`: the three lead writes, and
53
64
  * since #7633 the two schedule calls a site's same-origin relay forwards
54
- * (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book`. The
55
- * global limiter (`server.ts`) passes the hub's digests, so a listed call is
65
+ * (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book` — and
66
+ * since #7479 the three listing-draft calls a spoke makes for its applicant
67
+ * (`POST /v1/project-listings`, `PATCH /v1/project-listings/drafts/:id`,
68
+ * `POST /v1/project-listings/drafts/:id/submit`). The global limiter (`server.ts`) passes the hub's digests, so a listed call is
56
69
  * counted per visitor there; a scope's own limiter does so only when its
57
70
  * registration passes `trustedKeyDigests` too, as the leads and schedule
58
71
  * scopes do. A believed visitor is
@@ -78,8 +91,17 @@ import type {
78
91
  FastifyRequest,
79
92
  FastifyReply,
80
93
  FastifyPluginCallback,
94
+ FastifyBaseLogger,
81
95
  } from "fastify";
82
96
 
97
+ import { prisma } from "../lib/prisma.js";
98
+ import {
99
+ createDatabaseRateLimitStore,
100
+ resolveRateLimitStoreKind,
101
+ type RateLimitBucket,
102
+ type RateLimitStore,
103
+ } from "../lib/rate-limit-store.js";
104
+
83
105
  export type RateLimitOptions = {
84
106
  /**
85
107
  * Requests allowed per IP per window.
@@ -101,7 +123,8 @@ export type RateLimitOptions = {
101
123
  /**
102
124
  * Maximum distinct entries `buckets` may hold at once, independent of the
103
125
  * time-based eviction. Default: `MAX_BUCKETS`. Exposed for tests only —
104
- * no production caller overrides this.
126
+ * no production caller overrides this. Has no effect on the database
127
+ * store, which has no entry cap of its own (D6's sweep bounds it instead).
105
128
  */
106
129
  maxBuckets?: number;
107
130
  /**
@@ -123,11 +146,15 @@ export type RateLimitOptions = {
123
146
  * visitors (#7394). Default: 300.
124
147
  */
125
148
  perKeyPerMinute?: number;
126
- };
127
-
128
- type Bucket = {
129
- count: number;
130
- windowStartMs: number;
149
+ /**
150
+ * Override the process-default store (#5040 D4). Test-only, like
151
+ * `maxBuckets` — no production caller overrides this.
152
+ */
153
+ store?: RateLimitStore;
154
+ /**
155
+ * Override `STORE_TIMEOUT_MS` (#5040 D5). Test-only.
156
+ */
157
+ storeTimeoutMs?: number;
131
158
  };
132
159
 
133
160
  const RATE_LIMITED = "rate_limited" as const;
@@ -154,15 +181,26 @@ export const MAX_BUCKETS = 10_000;
154
181
  */
155
182
  export const DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE = 60;
156
183
 
184
+ /**
185
+ * How long a database-store call may run before the middleware falls back to
186
+ * the memory store for that request (#5040 D5). `RateLimitOptions.storeTimeoutMs`
187
+ * overrides it, test-only.
188
+ */
189
+ export const STORE_TIMEOUT_MS = 1_000;
190
+
191
+ /** How often a `rate_limit_store_fallback` warn line may be logged (D5). */
192
+ const FALLBACK_WARN_THROTTLE_MS = 60_000;
193
+
157
194
  /**
158
195
  * The only (method, route) pairs on which a forwarded visitor IP is believed
159
- * (#7394 DD-2): the three lead writes, and the two schedule calls a site's
196
+ * (#7394 DD-2): the three lead writes, the two schedule calls a site's
160
197
  * relay forwards on its visitors' behalf (#7633, the relay's security
161
- * review). Matched against `req.method` and `req.routeOptions.url`, the
198
+ * review), the two consent writes (#8438) and the three listing-draft calls
199
+ * (#7479). Matched against `req.method` and `req.routeOptions.url`, the
162
200
  * registered route pattern.
163
201
  */
164
202
  export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
165
- method: "GET" | "POST";
203
+ method: "GET" | "POST" | "PATCH";
166
204
  url: string;
167
205
  }>[] = [
168
206
  { method: "POST", url: "/v1/leads/submit" },
@@ -170,6 +208,16 @@ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
170
208
  { method: "POST", url: "/v1/leads/callback" },
171
209
  { method: "GET", url: "/v1/schedule/availability" },
172
210
  { method: "POST", url: "/v1/schedule/book" },
211
+ // #8438 D5/D9 — the cookie banner's log and the unsubscribe landing: a
212
+ // site's server forwards each visitor's IP, so one spoke's visitors are
213
+ // counted one by one rather than in one bucket keyed on its egress IP.
214
+ { method: "POST", url: "/v1/consent/cookie" },
215
+ { method: "POST", url: "/v1/consent/unsubscribe" },
216
+ // #7479 — the listing-draft lifecycle a spoke's Server Action calls on its
217
+ // applicant's behalf (`start`, the draft `patch`, `submit`), in `leadsScope`.
218
+ { method: "POST", url: "/v1/project-listings" },
219
+ { method: "PATCH", url: "/v1/project-listings/drafts/:id" },
220
+ { method: "POST", url: "/v1/project-listings/drafts/:id/submit" },
173
221
  ];
174
222
 
175
223
  const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
@@ -330,16 +378,17 @@ function resolveScopedLimit(
330
378
  return fallback;
331
379
  }
332
380
 
333
- // Module-scoped bucket store. One entry per unique identity, per named scope
334
- // (#7394). Per-forwarding-key buckets live in `keyBuckets` below.
335
- const buckets = new Map<string, Bucket>();
381
+ // Module-scoped bucket store (the memory store's backing state). One entry
382
+ // per unique identity, per named scope (#7394). Per-forwarding-key buckets
383
+ // live in `keyBuckets` below.
384
+ const buckets = new Map<string, RateLimitBucket>();
336
385
 
337
386
  // Per-forwarding-key buckets (#7394 DD-8), kept apart from `buckets` so the
338
387
  // `maxBuckets` FIFO eviction can never drop one: a keyed caller churning
339
388
  // visitor IPs would otherwise push its own key bucket out and reset its
340
389
  // ceiling. At most one entry per configured key per named scope, so it
341
390
  // needs no capacity cap of its own.
342
- const keyBuckets = new Map<string, Bucket>();
391
+ const keyBuckets = new Map<string, RateLimitBucket>();
343
392
 
344
393
  // Request counter used to trigger periodic cleanup.
345
394
  let requestsSinceLastClean = 0;
@@ -356,7 +405,6 @@ function evictStaleBuckets(now: number, windowMs: number): void {
356
405
  keyBuckets.delete(key);
357
406
  }
358
407
  }
359
- requestsSinceLastClean = 0;
360
408
  }
361
409
 
362
410
  /**
@@ -377,6 +425,145 @@ export function __bucketCount(): number {
377
425
  return buckets.size;
378
426
  }
379
427
 
428
+ /**
429
+ * The memory store (#5040 D1/D7): the Maps and eviction logic above, wrapped
430
+ * behind the `RateLimitStore` seam, unchanged in behavior. `maxBuckets` is
431
+ * captured per call (registrations may pass their own, test-only) but every
432
+ * instance still operates on the SAME shared `buckets` / `keyBuckets` Maps —
433
+ * there has never been more than one memory store's worth of state in this
434
+ * process.
435
+ */
436
+ function memoryRateLimitStore(maxBuckets: number): RateLimitStore {
437
+ function hit(key: string, now: number, windowMs: number): Promise<RateLimitBucket> {
438
+ let bucket = buckets.get(key);
439
+
440
+ if (!bucket || now - bucket.windowStartMs >= windowMs) {
441
+ // A genuinely new distinct identity (not a window roll on an
442
+ // existing bucket) at capacity evicts the single oldest-inserted
443
+ // entry first — independent of the time-based eviction above,
444
+ // which only removes EXPIRED windows and may leave `buckets` at or
445
+ // above `maxBuckets` when every entry is still within its window.
446
+ if (!buckets.has(key) && buckets.size >= maxBuckets) {
447
+ const oldestKey = buckets.keys().next().value;
448
+ if (oldestKey !== undefined) {
449
+ buckets.delete(oldestKey);
450
+ }
451
+ }
452
+ bucket = { count: 0, windowStartMs: now };
453
+ buckets.set(key, bucket);
454
+ }
455
+
456
+ bucket.count += 1;
457
+ return Promise.resolve(bucket);
458
+ }
459
+
460
+ function hitKey(key: string, now: number, windowMs: number): Promise<RateLimitBucket> {
461
+ let bucket = keyBuckets.get(key);
462
+ if (!bucket || now - bucket.windowStartMs >= windowMs) {
463
+ bucket = { count: 0, windowStartMs: now };
464
+ keyBuckets.set(key, bucket);
465
+ }
466
+ bucket.count += 1;
467
+ return Promise.resolve(bucket);
468
+ }
469
+
470
+ function hasOpenWindow(key: string, now: number, windowMs: number): Promise<boolean> {
471
+ const bucket = buckets.get(key);
472
+ return Promise.resolve(
473
+ bucket !== undefined && now - bucket.windowStartMs < windowMs,
474
+ );
475
+ }
476
+
477
+ // D6 — the memory store sweeps on both triggers, exactly as
478
+ // evictStaleBuckets always has; `reason` only matters to the database
479
+ // store.
480
+ function sweep(now: number, windowMs: number): Promise<void> {
481
+ evictStaleBuckets(now, windowMs);
482
+ return Promise.resolve();
483
+ }
484
+
485
+ return { hit, hitKey, hasOpenWindow, sweep };
486
+ }
487
+
488
+ // The process-wide default store (#5040 D4), resolved once and memoized —
489
+ // NOT reset by __resetBuckets(), since only a fresh module instance
490
+ // (jest.isolateModules) changes which store the environment resolves to.
491
+ let processStoreKind: "memory" | "database" | undefined;
492
+ let processDatabaseStore: RateLimitStore | undefined;
493
+ let loggedStoreKind = false;
494
+
495
+ /**
496
+ * A rejection or timeout fired this long ago, within `FALLBACK_WARN_THROTTLE_MS`
497
+ * — do not log another `rate_limit_store_fallback` line yet.
498
+ */
499
+ let lastFallbackWarnAt: number | undefined;
500
+
501
+ /** The process default store for a registration with no explicit `store` option. */
502
+ function resolveProcessDefaultStore(
503
+ warn: (msg: string) => void,
504
+ log: Pick<FastifyBaseLogger, "info">,
505
+ maxBuckets: number,
506
+ ): RateLimitStore {
507
+ if (processStoreKind === undefined) {
508
+ processStoreKind = resolveRateLimitStoreKind(
509
+ {
510
+ DATABASE_URL: process.env.DATABASE_URL,
511
+ AILK_RATE_LIMIT_STORE: process.env.AILK_RATE_LIMIT_STORE,
512
+ },
513
+ warn,
514
+ );
515
+ }
516
+ if (!loggedStoreKind) {
517
+ loggedStoreKind = true;
518
+ log.info({ rateLimitStore: processStoreKind }, "rate_limit_store");
519
+ }
520
+ if (processStoreKind === "database") {
521
+ processDatabaseStore ??= createDatabaseRateLimitStore(prisma);
522
+ return processDatabaseStore;
523
+ }
524
+ return memoryRateLimitStore(maxBuckets);
525
+ }
526
+
527
+ class RateLimitStoreTimeoutError extends Error {}
528
+
529
+ /** Reject with a `RateLimitStoreTimeoutError` if `promise` has not settled within `ms`. */
530
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
531
+ return new Promise<T>((resolve, reject) => {
532
+ const timer = setTimeout(() => {
533
+ reject(new RateLimitStoreTimeoutError("rate limit store call timed out"));
534
+ }, ms);
535
+ promise.then(
536
+ (value) => {
537
+ clearTimeout(timer);
538
+ resolve(value);
539
+ },
540
+ (err: unknown) => {
541
+ clearTimeout(timer);
542
+ reject(err instanceof Error ? err : new Error(String(err)));
543
+ },
544
+ );
545
+ });
546
+ }
547
+
548
+ /**
549
+ * Log at most one `rate_limit_store_fallback` warn per `FALLBACK_WARN_THROTTLE_MS`
550
+ * per process (#5040 D5). Carries no key, IP, hash or error message.
551
+ */
552
+ function warnFallback(
553
+ warn: (obj: Record<string, unknown>, msg: string) => void,
554
+ now: number,
555
+ reason: "error" | "timeout",
556
+ ): void {
557
+ if (
558
+ lastFallbackWarnAt !== undefined &&
559
+ now - lastFallbackWarnAt < FALLBACK_WARN_THROTTLE_MS
560
+ ) {
561
+ return;
562
+ }
563
+ lastFallbackWarnAt = now;
564
+ warn({ event: "rate_limit_store_fallback", reason }, "rate_limit_store_fallback");
565
+ }
566
+
380
567
  // Break Fastify's plugin encapsulation so the onRequest hook propagates to
381
568
  // parent and sibling scopes. This is the manual equivalent of fastify-plugin's
382
569
  // `skip-override` mechanism — documented in Fastify's plugin guide.
@@ -391,6 +578,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
391
578
  maxBuckets = MAX_BUCKETS,
392
579
  scope,
393
580
  trustedKeyDigests = [],
581
+ storeTimeoutMs = STORE_TIMEOUT_MS,
394
582
  } = opts;
395
583
 
396
584
  if (scope !== undefined && !SCOPE_RE.test(scope)) {
@@ -430,45 +618,57 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
430
618
  const bucketKey = (identity: string) =>
431
619
  scope === undefined ? identity : `${scope}|${identity}`;
432
620
 
433
- /** Count one request against `key`, opening a new window when needed. */
434
- function hit(key: string, now: number): Bucket {
435
- let bucket = buckets.get(key);
621
+ const fallbackStore = memoryRateLimitStore(maxBuckets);
622
+ const primaryStore: RateLimitStore =
623
+ opts.store ?? resolveProcessDefaultStore(warn, fastify.log, maxBuckets);
436
624
 
437
- if (!bucket || now - bucket.windowStartMs >= windowMs) {
438
- // A genuinely new distinct identity (not a window roll on an
439
- // existing bucket) at capacity evicts the single oldest-inserted
440
- // entry first — independent of the time-based eviction above,
441
- // which only removes EXPIRED windows and may leave `buckets` at or
442
- // above `maxBuckets` when every entry is still within its window.
443
- if (!buckets.has(key) && buckets.size >= maxBuckets) {
444
- const oldestKey = buckets.keys().next().value;
445
- if (oldestKey !== undefined) {
446
- buckets.delete(oldestKey);
447
- }
448
- }
449
- bucket = { count: 0, windowStartMs: now };
450
- buckets.set(key, bucket);
625
+ /**
626
+ * Run the whole request's counting against `activeStore` (D7's unchanged
627
+ * control flow: hitKey → keyOver → R-1 check (hasOpenWindow) → hit →
628
+ * tripped), plus the periodic/on-429 sweep (D6).
629
+ */
630
+ async function countRequest(
631
+ activeStore: RateLimitStore,
632
+ ip: string,
633
+ keyIndex: number | null,
634
+ now: number,
635
+ sweepOnInterval: boolean,
636
+ ): Promise<{
637
+ tripped: RateLimitBucket | undefined;
638
+ visitorOver: boolean;
639
+ keyOver: boolean;
640
+ }> {
641
+ if (sweepOnInterval) {
642
+ await activeStore.sweep(now, windowMs, "interval");
451
643
  }
452
644
 
453
- bucket.count += 1;
454
- return bucket;
455
- }
456
-
457
- /** True when `key` has a bucket whose window has not yet ended. */
458
- function hasOpenWindow(key: string, now: number): boolean {
459
- const bucket = buckets.get(key);
460
- return bucket !== undefined && now - bucket.windowStartMs < windowMs;
461
- }
462
-
463
- /** Count one request against a forwarding key's own bucket (DD-8). */
464
- function hitKey(key: string, now: number): Bucket {
465
- let bucket = keyBuckets.get(key);
466
- if (!bucket || now - bucket.windowStartMs >= windowMs) {
467
- bucket = { count: 0, windowStartMs: now };
468
- keyBuckets.set(key, bucket);
645
+ // #7394 DD-8: a keyed request also counts against its key's own
646
+ // bucket, even when the visitor bucket is already over its limit.
647
+ const keyBucket =
648
+ keyIndex === null
649
+ ? undefined
650
+ : await activeStore.hitKey(`${scope}|key,${keyIndex}`, now, windowMs);
651
+ const keyOver =
652
+ keyBucket !== undefined && keyBucket.count > perKeyPerMinute;
653
+ // A key over its ceiling opens no NEW visitor bucket (#7394 security
654
+ // review R-1). Each new entry can evict the oldest one in `buckets`,
655
+ // another scope's counter included, so without this a key holder
656
+ // rotating visitor IPs could wipe every strict limiter at will. A
657
+ // visitor whose window is still open is counted as usual.
658
+ const visitorKey = bucketKey(ip);
659
+ const bucket =
660
+ keyOver && !(await activeStore.hasOpenWindow(visitorKey, now, windowMs))
661
+ ? undefined
662
+ : await activeStore.hit(visitorKey, now, windowMs);
663
+ const visitorOver = bucket !== undefined && bucket.count > perIpPerMinute;
664
+ const tripped = visitorOver ? bucket : keyOver ? keyBucket : undefined;
665
+
666
+ if (tripped) {
667
+ // Evict stale buckets on every 429 to cap memory under abuse.
668
+ await activeStore.sweep(now, windowMs, "limited");
469
669
  }
470
- bucket.count += 1;
471
- return bucket;
670
+
671
+ return { tripped, visitorOver, keyOver };
472
672
  }
473
673
 
474
674
  fastify.addHook(
@@ -478,37 +678,50 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
478
678
  const { identity: ip, keyIndex } = identityOf(req);
479
679
 
480
680
  requestsSinceLastClean += 1;
481
- if (requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS) {
482
- evictStaleBuckets(now, windowMs);
681
+ const sweepOnInterval = requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS;
682
+ if (sweepOnInterval) requestsSinceLastClean = 0;
683
+
684
+ // #5040 D5 — fail open to the memory store, never to "no limit": a
685
+ // rejection or a timeout on ANY call of the primary store redoes the
686
+ // WHOLE request's counting against the memory store, so one request
687
+ // never mixes stores. withTimeout does not cancel the primary store's
688
+ // in-flight call, so a write that already landed before the failure
689
+ // stays counted, and a timed-out query may still land later — the
690
+ // request can be counted in BOTH stores, over-counting by at most
691
+ // one. That is the safe direction (D5): never under-counts, never
692
+ // lets a request through uncounted.
693
+ let outcome: {
694
+ tripped: RateLimitBucket | undefined;
695
+ visitorOver: boolean;
696
+ keyOver: boolean;
697
+ };
698
+ try {
699
+ outcome = await withTimeout(
700
+ countRequest(primaryStore, ip, keyIndex, now, sweepOnInterval),
701
+ storeTimeoutMs,
702
+ );
703
+ } catch (err) {
704
+ warnFallback(
705
+ (obj, msg) => fastify.log.warn(obj, msg),
706
+ now,
707
+ err instanceof RateLimitStoreTimeoutError ? "timeout" : "error",
708
+ );
709
+ outcome = await countRequest(
710
+ fallbackStore,
711
+ ip,
712
+ keyIndex,
713
+ now,
714
+ sweepOnInterval,
715
+ );
483
716
  }
484
717
 
485
- // #7394 DD-8: a keyed request also counts against its key's own
486
- // bucket, even when the visitor bucket is already over its limit.
487
- const keyBucket =
488
- keyIndex === null ? undefined : hitKey(`${scope}|key,${keyIndex}`, now);
489
- const keyOver =
490
- keyBucket !== undefined && keyBucket.count > perKeyPerMinute;
491
- // A key over its ceiling opens no NEW visitor bucket (#7394 security
492
- // review R-1). Each new entry can evict the oldest one in `buckets`,
493
- // another scope's counter included, so without this a key holder
494
- // rotating visitor IPs could wipe every strict limiter at will. A
495
- // visitor whose window is still open is counted as usual.
496
- const visitorKey = bucketKey(ip);
497
- const bucket =
498
- keyOver && !hasOpenWindow(visitorKey, now)
499
- ? undefined
500
- : hit(visitorKey, now);
501
- const visitorOver = bucket !== undefined && bucket.count > perIpPerMinute;
502
- const tripped = visitorOver ? bucket : keyOver ? keyBucket : undefined;
718
+ const { tripped, visitorOver } = outcome;
503
719
 
504
720
  if (tripped) {
505
721
  const retryAfterSeconds = Math.ceil(
506
722
  (tripped.windowStartMs + windowMs - now) / 1_000,
507
723
  );
508
724
 
509
- // Evict stale buckets on every 429 to cap memory under abuse.
510
- evictStaleBuckets(now, windowMs);
511
-
512
725
  // The path WITHOUT its query string (#5970 security review, finding 1): a
513
726
  // route may carry its credential there (`/v1/requests/:id/confirm?token=…`
514
727
  // does), and a rate-limit warning is a line an attacker can provoke at
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @file live-key.test.ts
3
+ *
4
+ * The hub checkout with a live key (#8450 AC 1, AC 2): `POST
5
+ * /v1/checkout/sessions` and the return page's `GET
6
+ * /v1/checkout/sessions/:id`. Unlike `sessions.test.ts` and
7
+ * `session.test.ts`, BOTH guards here are real: `lib/stripe.ts` runs as
8
+ * itself (only the two Stripe calls are mocked, so nothing leaves the
9
+ * process), and `@working-theory/stripe-client` runs as itself with
10
+ * `assertSandboxKey` wrapped in a spy, so the test can see whether a route
11
+ * still calls the sandbox-only guard.
12
+ *
13
+ * The process admits a restricted live key once, in `beforeAll`, exactly as
14
+ * `build()` does at boot. Every live-shaped key is composed at runtime from
15
+ * fragments — never a literal (architecture.yaml's live-key pattern).
16
+ */
17
+ import type { FastifyInstance } from "fastify";
18
+
19
+ const mockCreateCheckoutSession = jest.fn();
20
+ const mockRetrieveCheckoutSession = jest.fn();
21
+
22
+ jest.mock("@working-theory/stripe-client", () => {
23
+ const actual = jest.requireActual("@working-theory/stripe-client");
24
+ return { ...actual, assertSandboxKey: jest.fn(actual.assertSandboxKey) };
25
+ });
26
+
27
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
28
+ jest.mock("../../../lib/stripe.js", () => ({
29
+ ...jest.requireActual("../../../lib/stripe.js"),
30
+ createCheckoutSession: (...args: unknown[]) => mockCreateCheckoutSession(...args),
31
+ retrieveCheckoutSession: (...args: unknown[]) => mockRetrieveCheckoutSession(...args),
32
+ }));
33
+
34
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; the DB boundary, mirrors sessions.test.ts
35
+ jest.mock("../../../lib/prisma.js", () => ({
36
+ prisma: {
37
+ session: { findUnique: jest.fn() },
38
+ user: { findUnique: jest.fn() },
39
+ apiToken: { findUnique: jest.fn(), update: jest.fn() },
40
+ },
41
+ }));
42
+
43
+ import { assertSandboxKey } from "@working-theory/stripe-client";
44
+
45
+ import { config } from "../../../config/index.js";
46
+ import { admitApiStripeKey, stripeKeyMode } from "../../../lib/stripe.js";
47
+ import { __resetBuckets } from "../../../middleware/rate-limit.js";
48
+ import { checkoutRoutes } from "../index";
49
+
50
+ config.FRONTEND_URL = "https://example.com";
51
+ config.TRUSTED_ORIGINS = ["https://example.com"];
52
+
53
+ const TAIL = "FixtureOnlyCheckout";
54
+ const restrictedLiveKey = ["rk", "live", ""].join("_") + TAIL;
55
+ const secretLiveKey = ["sk", "live", ""].join("_") + TAIL;
56
+ const publishableLiveKey = ["pk", "live", ""].join("_") + TAIL;
57
+
58
+ const sandboxSpy = assertSandboxKey as jest.MockedFunction<typeof assertSandboxKey>;
59
+
60
+ const ENV_KEYS = ["STRIPE_SECRET_KEY", "STRIPE_MODE", "VERCEL_ENV", "OSS_API_INTERNAL_SECRET"] as const;
61
+ const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
62
+
63
+ const VALID_BODY = {
64
+ productCode: "ailk-pro",
65
+ successUrl: "https://example.com/success",
66
+ cancelUrl: "https://example.com/cancel",
67
+ };
68
+
69
+ const SESSION_ID = "cs_live_fixture12345";
70
+
71
+ function buildApp(): FastifyInstance {
72
+ const fastify = require("fastify")({ logger: false });
73
+ fastify.register(checkoutRoutes);
74
+ return fastify;
75
+ }
76
+
77
+ describe("the hub checkout — an admitted live key (#8450)", () => {
78
+ let app: FastifyInstance;
79
+
80
+ beforeAll(() => {
81
+ delete process.env["OSS_API_INTERNAL_SECRET"];
82
+ process.env["STRIPE_SECRET_KEY"] = restrictedLiveKey;
83
+ process.env["STRIPE_MODE"] = "live";
84
+ process.env["VERCEL_ENV"] = "production";
85
+ expect(admitApiStripeKey()).toBe("live");
86
+ expect(stripeKeyMode()).toBe("live");
87
+ });
88
+
89
+ afterAll(() => {
90
+ for (const k of ENV_KEYS) {
91
+ if (savedEnv[k] === undefined) delete process.env[k];
92
+ else process.env[k] = savedEnv[k];
93
+ }
94
+ });
95
+
96
+ beforeEach(async () => {
97
+ jest.clearAllMocks();
98
+ __resetBuckets();
99
+ process.env["STRIPE_SECRET_KEY"] = restrictedLiveKey;
100
+ mockCreateCheckoutSession.mockResolvedValue({
101
+ sessionId: "cs_live_fixture12345",
102
+ url: "https://checkout.stripe.com/c/pay/fixture",
103
+ });
104
+ mockRetrieveCheckoutSession.mockResolvedValue({
105
+ id: SESSION_ID,
106
+ status: "complete",
107
+ paymentStatus: "paid",
108
+ currency: "usd",
109
+ amountTotal: 4800,
110
+ lineItems: [],
111
+ });
112
+ app = buildApp();
113
+ await app.ready();
114
+ });
115
+
116
+ afterEach(async () => {
117
+ await app.close();
118
+ });
119
+
120
+ describe("POST /v1/checkout/sessions", () => {
121
+ it("gets past the key check on the restricted live key, without the sandbox-only guard", async () => {
122
+ const res = await app.inject({ method: "POST", url: "/v1/checkout/sessions", payload: VALID_BODY });
123
+
124
+ expect(res.statusCode).toBe(201);
125
+ expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
126
+ expect(sandboxSpy).not.toHaveBeenCalled();
127
+ });
128
+
129
+ it.each([
130
+ ["an unrestricted live key", secretLiveKey],
131
+ ["a publishable live key", publishableLiveKey],
132
+ ])("still refuses %s with 500 live-mode-forbidden and no Stripe call", async (_label, key) => {
133
+ process.env["STRIPE_SECRET_KEY"] = key;
134
+
135
+ const res = await app.inject({ method: "POST", url: "/v1/checkout/sessions", payload: VALID_BODY });
136
+
137
+ expect(res.statusCode).toBe(500);
138
+ expect(res.json()).toEqual({ error: "live-mode-forbidden" });
139
+ expect(res.body).not.toContain(TAIL);
140
+ expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
141
+ });
142
+ });
143
+
144
+ describe("GET /v1/checkout/sessions/:id", () => {
145
+ it("gets past the key check on the restricted live key, without the sandbox-only guard", async () => {
146
+ const res = await app.inject({ method: "GET", url: `/v1/checkout/sessions/${SESSION_ID}` });
147
+
148
+ expect(res.statusCode).toBe(200);
149
+ expect(mockRetrieveCheckoutSession).toHaveBeenCalledTimes(1);
150
+ expect(sandboxSpy).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it.each([
154
+ ["an unrestricted live key", secretLiveKey],
155
+ ["a publishable live key", publishableLiveKey],
156
+ ])("still refuses %s with 500 live-mode-forbidden and no Stripe call", async (_label, key) => {
157
+ process.env["STRIPE_SECRET_KEY"] = key;
158
+
159
+ const res = await app.inject({ method: "GET", url: `/v1/checkout/sessions/${SESSION_ID}` });
160
+
161
+ expect(res.statusCode).toBe(500);
162
+ expect(res.json()).toEqual({ error: "live-mode-forbidden" });
163
+ expect(res.body).not.toContain(TAIL);
164
+ expect(mockRetrieveCheckoutSession).not.toHaveBeenCalled();
165
+ });
166
+ });
167
+ });