create-ailk 0.27.0 → 0.28.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 (210) hide show
  1. package/README.md +84 -7
  2. package/dist/add-module-core.js +2 -1
  3. package/dist/add-module.d.ts +2 -1
  4. package/dist/add-module.js +9 -4
  5. package/dist/add-route.js +46 -19
  6. package/dist/body-heading.d.ts +56 -0
  7. package/dist/body-heading.js +394 -0
  8. package/dist/cli.js +28 -1
  9. package/dist/content-payloads.js +2 -2
  10. package/dist/derive-architecture.js +8 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +4 -1
  13. package/dist/lib/auth-secret-file.d.ts +6 -2
  14. package/dist/lib/auth-secret-file.js +46 -20
  15. package/dist/lib/env-catalog.js +20 -0
  16. package/dist/lib/site-modules.d.ts +8 -0
  17. package/dist/lib/site-modules.js +34 -0
  18. package/dist/module-architecture.js +23 -14
  19. package/dist/paid-sections.d.ts +17 -0
  20. package/dist/paid-sections.js +39 -0
  21. package/dist/programmatic.d.ts +16 -0
  22. package/dist/programmatic.js +73 -4
  23. package/dist/remove-module.d.ts +98 -0
  24. package/dist/remove-module.js +620 -0
  25. package/dist/seed-flows.d.ts +2 -2
  26. package/dist/seed-flows.js +16 -9
  27. package/dist/site-roster.js +4 -1
  28. package/dist/starter-grid.d.ts +26 -0
  29. package/dist/starter-grid.js +34 -0
  30. package/dist/surfaces.d.ts +26 -0
  31. package/dist/surfaces.js +105 -9
  32. package/dist/sync-routes.d.ts +7 -0
  33. package/dist/sync-routes.js +53 -6
  34. package/dist/template-pages.d.ts +36 -0
  35. package/dist/template-pages.js +129 -0
  36. package/dist/topology.d.ts +2 -1
  37. package/dist/topology.js +4 -2
  38. package/dist/update.d.ts +3 -1
  39. package/dist/update.js +38 -4
  40. package/package.json +3 -3
  41. package/release-scope.json +2 -1
  42. package/site-templates/aliases/hub.TEMPLATE-MANIFEST.json +115 -0
  43. package/templates/.env.example +5 -2
  44. package/templates/apps/api/.env.example +9 -5
  45. package/templates/apps/api/CLAUDE.md +14 -9
  46. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +37 -0
  47. package/templates/apps/api/src/vercel-handler.ts +8 -1
  48. package/templates/apps/mcp/CLAUDE.md +18 -10
  49. package/templates/apps/web/.env.example +25 -11
  50. package/templates/apps/web/CLAUDE.md +45 -4
  51. package/templates/apps/web/__tests__/setup.ts +7 -0
  52. package/templates/apps/web/app/.well-known/mcp.json/route.ts +7 -7
  53. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +61 -2
  54. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +81 -0
  55. package/templates/apps/web/app/[locale]/(authed)/account/connect/__tests__/page.test.tsx +197 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/connect/page.tsx +152 -0
  57. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  58. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +76 -21
  59. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +46 -16
  60. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +20 -3
  61. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +180 -2
  62. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +6 -2
  63. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +122 -0
  64. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +1 -1
  65. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +20 -3
  66. package/templates/apps/web/app/[locale]/case-studies/__tests__/hub-children.test.tsx +65 -0
  67. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +6 -2
  68. package/templates/apps/web/app/[locale]/case-studies/page.tsx +23 -11
  69. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +11 -1
  70. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  71. package/templates/apps/web/app/[locale]/consent.actions.ts +44 -6
  72. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +4 -4
  73. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +269 -42
  74. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +88 -30
  75. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +20 -3
  76. package/templates/apps/web/app/[locale]/courses/__tests__/hub-children.test.tsx +32 -0
  77. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +6 -2
  78. package/templates/apps/web/app/[locale]/courses/page.tsx +22 -6
  79. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +15 -2
  80. package/templates/apps/web/app/[locale]/events/__tests__/hub-children.test.tsx +32 -0
  81. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +8 -2
  82. package/templates/apps/web/app/[locale]/events/page.tsx +23 -7
  83. package/templates/apps/web/app/[locale]/features/__tests__/hub-children.test.tsx +32 -0
  84. package/templates/apps/web/app/[locale]/features/page.tsx +23 -25
  85. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +95 -0
  86. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/save.actions.test.ts +193 -0
  87. package/templates/apps/web/app/[locale]/flows/[slug]/email-moment.ts +92 -0
  88. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +10 -0
  89. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +513 -0
  90. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +122 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.no-commerce.test.tsx +195 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/landing.test.tsx +293 -0
  93. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +397 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/checkout-seam.ts +74 -0
  95. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +264 -0
  96. package/templates/apps/web/app/[locale]/flows/[slug]/save.actions.ts +75 -0
  97. package/templates/apps/web/app/[locale]/for/__tests__/hub-children.test.tsx +32 -0
  98. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +5 -1
  99. package/templates/apps/web/app/[locale]/for/page.tsx +23 -25
  100. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +20 -3
  101. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  102. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +25 -4
  103. package/templates/apps/web/app/[locale]/integrations/__tests__/hub-children.test.tsx +38 -0
  104. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +8 -2
  105. package/templates/apps/web/app/[locale]/integrations/page.tsx +23 -11
  106. package/templates/apps/web/app/[locale]/layout.tsx +157 -139
  107. package/templates/apps/web/app/[locale]/page.tsx +51 -3
  108. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +16 -3
  109. package/templates/apps/web/app/[locale]/partners/__tests__/hub-children.test.tsx +32 -0
  110. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +6 -2
  111. package/templates/apps/web/app/[locale]/partners/page.tsx +22 -6
  112. package/templates/apps/web/app/[locale]/portfolio/__tests__/hub-children.test.tsx +38 -0
  113. package/templates/apps/web/app/[locale]/portfolio/page.tsx +25 -24
  114. package/templates/apps/web/app/[locale]/pricing/[slug]/__tests__/page.test.tsx +321 -0
  115. package/templates/apps/web/app/[locale]/pricing/[slug]/page.tsx +87 -0
  116. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +3 -0
  117. package/templates/apps/web/app/[locale]/pricing/__tests__/pricing-pages.test.tsx +215 -0
  118. package/templates/apps/web/app/[locale]/pricing/page.tsx +56 -341
  119. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +21 -4
  120. package/templates/apps/web/app/[locale]/products/__tests__/hub-children.test.tsx +32 -0
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +8 -2
  122. package/templates/apps/web/app/[locale]/products/page.tsx +22 -6
  123. package/templates/apps/web/app/[locale]/projects/__tests__/hub-children.test.tsx +32 -0
  124. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +4 -0
  125. package/templates/apps/web/app/[locale]/projects/page.tsx +18 -11
  126. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +25 -4
  127. package/templates/apps/web/app/[locale]/resources/__tests__/hub-children.test.tsx +38 -0
  128. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +6 -2
  129. package/templates/apps/web/app/[locale]/resources/page.tsx +20 -10
  130. package/templates/apps/web/app/[locale]/services/[slug]/[child]/page.tsx +68 -0
  131. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +64 -9
  132. package/templates/apps/web/app/[locale]/services/__tests__/hub-children.test.tsx +158 -0
  133. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +12 -2
  134. package/templates/apps/web/app/[locale]/services/__tests__/service-detail-sections.test.tsx +223 -0
  135. package/templates/apps/web/app/[locale]/services/__tests__/service-families.test.tsx +368 -0
  136. package/templates/apps/web/app/[locale]/services/page.tsx +45 -19
  137. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/team/__tests__/hub-children.test.tsx +32 -0
  139. package/templates/apps/web/app/[locale]/team/page.tsx +25 -24
  140. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +24 -3
  141. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  142. package/templates/apps/web/app/[locale]/templates/[slug]/__tests__/page.test.tsx +203 -0
  143. package/templates/apps/web/app/[locale]/templates/[slug]/page.tsx +81 -0
  144. package/templates/apps/web/app/[locale]/templates/__tests__/page.test.tsx +236 -0
  145. package/templates/apps/web/app/[locale]/templates/page.tsx +108 -0
  146. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  147. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +24 -3
  148. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  149. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +204 -2
  150. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +151 -33
  151. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +233 -7
  152. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +92 -41
  153. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +77 -39
  154. package/templates/apps/web/app/llms.txt/route.ts +41 -3
  155. package/templates/apps/web/app/robots.txt/route.ts +5 -1
  156. package/templates/apps/web/app/sitemap.xml/route.ts +45 -3
  157. package/templates/apps/web/auth.ts +102 -50
  158. package/templates/apps/web/components/signed-in-nav.tsx +8 -1
  159. package/templates/apps/web/lib/__tests__/bot-check.test.ts +254 -0
  160. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +212 -13
  161. package/templates/apps/web/lib/__tests__/deck-page.test.ts +13 -1
  162. package/templates/apps/web/lib/__tests__/docs-index.test.ts +93 -0
  163. package/templates/apps/web/lib/__tests__/docs-path.test.ts +168 -0
  164. package/templates/apps/web/lib/__tests__/hub-children.fixtures.ts +278 -0
  165. package/templates/apps/web/lib/__tests__/nav-label-keys.test.ts +138 -0
  166. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +178 -2
  167. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +183 -0
  168. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +412 -0
  169. package/templates/apps/web/lib/__tests__/pricing-page.test.ts +142 -0
  170. package/templates/apps/web/lib/__tests__/relay.test.ts +202 -10
  171. package/templates/apps/web/lib/__tests__/route-family.test.ts +57 -0
  172. package/templates/apps/web/lib/__tests__/service-listing.test.ts +111 -0
  173. package/templates/apps/web/lib/__tests__/services-path.test.ts +96 -0
  174. package/templates/apps/web/lib/__tests__/site-header.test.ts +151 -0
  175. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +148 -6
  176. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +84 -0
  177. package/templates/apps/web/lib/__tests__/template-listing.test.ts +158 -0
  178. package/templates/apps/web/lib/auth-secret.ts +57 -0
  179. package/templates/apps/web/lib/bot-check.ts +138 -0
  180. package/templates/apps/web/lib/consent-visitor.ts +81 -7
  181. package/templates/apps/web/lib/deck-page.ts +6 -3
  182. package/templates/apps/web/lib/docs-index.ts +100 -0
  183. package/templates/apps/web/lib/docs-path.ts +94 -0
  184. package/templates/apps/web/lib/hub-children.tsx +318 -0
  185. package/templates/apps/web/lib/modules.generated.ts +9 -0
  186. package/templates/apps/web/lib/nav-label-check.ts +61 -0
  187. package/templates/apps/web/lib/nav-label-keys.ts +78 -0
  188. package/templates/apps/web/lib/newsletter.actions.ts +31 -6
  189. package/templates/apps/web/lib/offerings-pricing.ts +135 -6
  190. package/templates/apps/web/lib/page-chrome.ts +367 -0
  191. package/templates/apps/web/lib/posting-title.ts +26 -4
  192. package/templates/apps/web/lib/pricing-page.ts +430 -0
  193. package/templates/apps/web/lib/relay.ts +31 -106
  194. package/templates/apps/web/lib/route-family.ts +69 -0
  195. package/templates/apps/web/lib/service-listing.ts +89 -0
  196. package/templates/apps/web/lib/services-path.ts +97 -0
  197. package/templates/apps/web/lib/site-header.ts +94 -0
  198. package/templates/apps/web/lib/telemetry-scrub.ts +68 -3
  199. package/templates/apps/web/lib/template-listing.ts +169 -0
  200. package/templates/apps/web/messages/en.json +86 -30
  201. package/templates/apps/web/middleware.ts +171 -11
  202. package/templates/apps/web/next.config.mjs +211 -2
  203. package/templates/apps/web/package.json +1 -1
  204. package/templates/apps/web/scripts/assert-content-bundled.ts +28 -6
  205. package/templates/apps/web/scripts/check-nav-labels.ts +39 -0
  206. package/templates/apps/web/sentry.server.config.ts +30 -2
  207. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -9
  208. package/templates/apps/web/src/lib/rate-limit.ts +68 -27
  209. package/templates/apps/web/src/lib/routes.ts +15 -1
  210. package/versions.json +48 -48
@@ -11,8 +11,13 @@
11
11
  * #7874 D5). The rest of the ladder is pinned in `../../__tests__/route.test.ts`.
12
12
  *
13
13
  * Each case loads the route in its own module registry, so no case reads
14
- * another's shared shown-set answer; a spoke case swaps in the spoke render
15
- * of both generated seams, as `lib/__tests__/relay.test.ts` does.
14
+ * another's shared shown-set answer or spends another's start budget; a spoke
15
+ * case swaps in the spoke render of both generated seams, as
16
+ * `lib/__tests__/relay.test.ts` does.
17
+ *
18
+ * The per-visitor start budget (#9965: #9059, #9063) is pinned at the end:
19
+ * keyed only on the address Vercel's edge writes, never skipped for a visitor
20
+ * with none, and spent only by a start the API made.
16
21
  */
17
22
  import { workspaceShownSetSchema } from "@working-theory/validation";
18
23
 
@@ -71,6 +76,7 @@ const ENV_NAMES = [
71
76
  "NODE_ENV",
72
77
  "OSS_API_INTERNAL_SECRET",
73
78
  "AILK_SPOKE_KEY",
79
+ "VERCEL",
74
80
  ] as const;
75
81
  const savedEnv = Object.fromEntries(ENV_NAMES.map((name) => [name, env[name]]));
76
82
 
@@ -83,16 +89,21 @@ function json(status: number, body: unknown): Promise<Response> {
83
89
  );
84
90
  }
85
91
 
86
- /** The shown-set read answers `shownStatus`; the session POST starts one. */
92
+ function started(): Promise<Response> {
93
+ return json(201, {
94
+ sessionId: "cs_test_a1",
95
+ url: "https://checkout.stripe.com/c/pay/cs_test_a1",
96
+ });
97
+ }
98
+
99
+ /** The shown-set read answers `shownStatus`; the session POST `sessionAnswer`. */
87
100
  let shownStatus: "unconnected" | "shown";
101
+ let sessionAnswer: () => Promise<Response>;
88
102
  const realFetch = global.fetch;
89
103
  const fetchMock = jest.fn(
90
104
  (input: string | URL | Request, _init?: RequestInit) => {
91
105
  if (new URL(String(input)).pathname === "/v1/checkout/sessions") {
92
- return json(201, {
93
- sessionId: "cs_test_a1",
94
- url: "https://checkout.stripe.com/c/pay/cs_test_a1",
95
- });
106
+ return sessionAnswer();
96
107
  }
97
108
  return json(200, {
98
109
  ok: true,
@@ -119,6 +130,7 @@ beforeEach(() => {
119
130
  env.NODE_ENV = "production";
120
131
  env.NEXT_PUBLIC_BASE_URL = "https://shop.example.com";
121
132
  shownStatus = "unconnected";
133
+ sessionAnswer = started;
122
134
  fetchMock.mockClear();
123
135
  warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
124
136
  });
@@ -163,6 +175,53 @@ async function buy(topology: "hub" | "spoke"): Promise<Response> {
163
175
  return res;
164
176
  }
165
177
 
178
+ type Get = (typeof import("../route"))["GET"];
179
+
180
+ /** The hub route in one module registry, so its calls share one budget. */
181
+ async function loadHubRoute(): Promise<Get> {
182
+ let get: Get | undefined;
183
+ await jest.isolateModulesAsync(async () => {
184
+ jest.dontMock(ORIGIN_SEAM);
185
+ jest.dontMock(TOPOLOGY_SEAM);
186
+ ({ GET: get } = await import("../route"));
187
+ });
188
+ if (get === undefined) throw new Error("the route did not load");
189
+ return get;
190
+ }
191
+
192
+ /** A buyer's click on `growth_monthly` carrying `headers`. */
193
+ function click(
194
+ get: Get,
195
+ headers: Record<string, string> = {},
196
+ ): Promise<Response> {
197
+ const request = new Request(
198
+ "https://shop.example.com/api/offerings/checkout/growth_monthly",
199
+ {
200
+ headers: {
201
+ "sec-fetch-mode": "navigate",
202
+ "sec-fetch-dest": "document",
203
+ "sec-fetch-site": "same-origin",
204
+ ...headers,
205
+ },
206
+ },
207
+ );
208
+ return get(request, {
209
+ params: Promise.resolve({ lookupKey: "growth_monthly" }),
210
+ });
211
+ }
212
+
213
+ function postCount(): number {
214
+ return fetchMock.mock.calls.filter(
215
+ ([input]) => new URL(String(input)).pathname === "/v1/checkout/sessions",
216
+ ).length;
217
+ }
218
+
219
+ function refusedFor(reason: string): number {
220
+ return warnSpy.mock.calls.filter(
221
+ ([line]) => line === `[offerings-checkout] refused reason=${reason}`,
222
+ ).length;
223
+ }
224
+
166
225
  function postHeaders(): Record<string, string> {
167
226
  const post = fetchMock.mock.calls.find(
168
227
  ([input]) => new URL(String(input)).pathname === "/v1/checkout/sessions",
@@ -220,3 +279,170 @@ describe("the route's API origin comes from the topology seam", () => {
220
279
  expect(postHeaders()["x-internal-auth"]).toBeUndefined();
221
280
  });
222
281
  });
282
+
283
+ describe("the per-visitor start budget (#9059, #9063)", () => {
284
+ beforeEach(() => {
285
+ env.AILK_API_URL = HUB_API;
286
+ shownStatus = "shown";
287
+ });
288
+
289
+ it("on Vercel, keys on x-vercel-forwarded-for, never a client-chosen x-forwarded-for", async () => {
290
+ env.VERCEL = "1";
291
+ const get = await loadHubRoute();
292
+
293
+ for (let i = 0; i < 6; i += 1) {
294
+ await click(get, {
295
+ "x-vercel-forwarded-for": "198.51.100.1",
296
+ "x-forwarded-for": `203.0.113.${i}`,
297
+ "x-real-ip": `192.0.2.${i}`,
298
+ });
299
+ }
300
+
301
+ expect(postCount()).toBe(5);
302
+ expect(refusedFor("rate_limited")).toBe(1);
303
+
304
+ await click(get, { "x-vercel-forwarded-for": "198.51.100.2" });
305
+ expect(postCount()).toBe(6);
306
+ });
307
+
308
+ it("off Vercel, reads no address header, so every visitor shares the one wide bucket", async () => {
309
+ const get = await loadHubRoute();
310
+
311
+ for (let i = 0; i < 51; i += 1) {
312
+ await click(get, {
313
+ "x-vercel-forwarded-for": `198.51.100.${i}`,
314
+ "x-forwarded-for": `203.0.113.${i}`,
315
+ });
316
+ }
317
+
318
+ expect(postCount()).toBe(50);
319
+ expect(refusedFor("rate_limited")).toBe(1);
320
+ });
321
+
322
+ it("charges a visitor with no readable address to the shared bucket, never skips one", async () => {
323
+ env.VERCEL = "1";
324
+ const get = await loadHubRoute();
325
+
326
+ for (let i = 0; i < 51; i += 1) await click(get);
327
+
328
+ expect(postCount()).toBe(50);
329
+ expect(refusedFor("rate_limited")).toBe(1);
330
+
331
+ // An addressed visitor keeps a budget of its own.
332
+ await click(get, { "x-vercel-forwarded-for": "198.51.100.1" });
333
+ expect(postCount()).toBe(51);
334
+ });
335
+
336
+ it("spends a start only when the API starts a Checkout", async () => {
337
+ env.VERCEL = "1";
338
+ const get = await loadHubRoute();
339
+ // The same address in both headers, so the unfixed keying counts it too.
340
+ const visitor = {
341
+ "x-vercel-forwarded-for": "198.51.100.1",
342
+ "x-forwarded-for": "198.51.100.1",
343
+ };
344
+
345
+ const failures = [
346
+ () => json(502, { error: "upstream" }),
347
+ () => json(503, { error: "unavailable" }),
348
+ () => json(500, { error: "internal" }),
349
+ () => Promise.reject(new TypeError("fetch failed")),
350
+ () => Promise.reject(new TypeError("fetch failed")),
351
+ ];
352
+ for (const failure of failures) {
353
+ sessionAnswer = failure;
354
+ await click(get, visitor);
355
+ }
356
+ expect(postCount()).toBe(5);
357
+ expect(refusedFor("rate_limited")).toBe(0);
358
+
359
+ sessionAnswer = started;
360
+ for (let i = 0; i < 5; i += 1) {
361
+ const res = await click(get, visitor);
362
+ expect(res.headers.get("location")).toBe(
363
+ "https://checkout.stripe.com/c/pay/cs_test_a1",
364
+ );
365
+ }
366
+ expect(postCount()).toBe(10);
367
+
368
+ const sixth = await click(get, visitor);
369
+ expect(sixth.headers.get("location")).toBe("/pricing");
370
+ expect(postCount()).toBe(10);
371
+ expect(refusedFor("rate_limited")).toBe(1);
372
+ });
373
+
374
+ it("holds a start in flight against the budget, so concurrent clicks cannot overrun it", async () => {
375
+ env.VERCEL = "1";
376
+ const get = await loadHubRoute();
377
+ // The same address in both headers, so the unfixed keying counts it too.
378
+ const visitor = {
379
+ "x-vercel-forwarded-for": "198.51.100.1",
380
+ "x-forwarded-for": "198.51.100.1",
381
+ };
382
+ const answers: Array<(res: Response) => void> = [];
383
+ sessionAnswer = () =>
384
+ new Promise<Response>((resolve) => {
385
+ answers.push(resolve);
386
+ });
387
+
388
+ const clicks = Array.from({ length: 6 }, () => click(get, visitor));
389
+ // The sixth is refused while the other five still wait on the API.
390
+ await new Promise((resolve) => setTimeout(resolve, 0));
391
+ expect(answers).toHaveLength(5);
392
+ expect(refusedFor("rate_limited")).toBe(1);
393
+
394
+ for (const answer of answers) {
395
+ answer(
396
+ new Response(JSON.stringify({ error: "upstream" }), {
397
+ status: 502,
398
+ headers: { "content-type": "application/json" },
399
+ }),
400
+ );
401
+ }
402
+ await Promise.all(clicks);
403
+
404
+ // Five failed starts spent nothing, so the visitor can start again.
405
+ sessionAnswer = started;
406
+ await click(get, visitor);
407
+ expect(postCount()).toBe(6);
408
+ });
409
+
410
+ it("a start in flight across the window's end settles on the window it holds", async () => {
411
+ env.VERCEL = "1";
412
+ const get = await loadHubRoute();
413
+ const visitor = { "x-vercel-forwarded-for": "198.51.100.1" };
414
+ const answers: Array<(res: Response) => void> = [];
415
+ sessionAnswer = () =>
416
+ new Promise<Response>((resolve) => {
417
+ answers.push(resolve);
418
+ });
419
+ const now = jest.spyOn(Date, "now");
420
+ try {
421
+ now.mockReturnValue(1_000_000);
422
+ const first = click(get, visitor);
423
+ await new Promise((resolve) => setTimeout(resolve, 0));
424
+ now.mockReturnValue(1_000_000 + 10 * 60_000);
425
+ const second = click(get, visitor);
426
+ await new Promise((resolve) => setTimeout(resolve, 0));
427
+ expect(answers).toHaveLength(2);
428
+
429
+ for (const answer of answers) {
430
+ answer(
431
+ new Response(JSON.stringify({ error: "upstream" }), {
432
+ status: 502,
433
+ headers: { "content-type": "application/json" },
434
+ }),
435
+ );
436
+ }
437
+ await Promise.all([first, second]);
438
+
439
+ // Both failed, so nothing is held: the window admits its full five.
440
+ sessionAnswer = started;
441
+ for (let i = 0; i < 6; i += 1) await click(get, visitor);
442
+ expect(postCount()).toBe(7);
443
+ expect(refusedFor("rate_limited")).toBe(1);
444
+ } finally {
445
+ now.mockRestore();
446
+ }
447
+ });
448
+ });
@@ -6,7 +6,9 @@
6
6
  // Only a buyer's page load starts anything: a prefetch, a crawler, a
7
7
  // cross-site load or an ungrammatical key is refused before any call. The
8
8
  // route then confirms, from a shown-set read shared for 15 seconds, that the
9
- // site is connected and shows the key — an unconnected site's POST would take
9
+ // site is connected and shows the key — and, for a bundle link, the required
10
+ // product's key it carries as `?with=` (#10028 D10), sent as the body's
11
+ // `requires` — an unconnected site's POST would take
10
12
  // the API's env path (#8931 A9) — and POSTs `/v1/checkout/sessions` with
11
13
  // success and cancel URLs on the site's configured origin, never the
12
14
  // request's. Every refusal is a 303 to `/pricing` with no body and one fixed
@@ -19,6 +21,10 @@ import { isShownLookupKey } from "../../../../../lib/offerings-pricing";
19
21
  import { FALLBACK_BASE_URL } from "../../../../../lib/site-brand";
20
22
  import { TOPOLOGY } from "../../../../../lib/topology.generated";
21
23
  import { resolveCommerceClaim } from "../../../../../src/lib/commerce-claim";
24
+ import {
25
+ clientIpFromHeaders,
26
+ UNKNOWN_VISITOR_FACTOR,
27
+ } from "../../../../../src/lib/rate-limit";
22
28
  import { resolveBaseUrl } from "../../../../../src/lib/routes";
23
29
 
24
30
  export const runtime = "nodejs";
@@ -166,37 +172,75 @@ function siteOrigin(): string | null {
166
172
  const BUDGET_STARTS = 5;
167
173
  const BUDGET_WINDOW_MS = 10 * 60_000;
168
174
  const BUDGET_MAX_ADDRESSES = 10_000;
169
- /** An address key is cut to this length, so the Map cannot grow by key size. */
170
- const BUDGET_ADDRESS_LENGTH = 64;
171
175
 
172
- const starts = new Map<string, { windowStart: number; count: number }>();
176
+ /** One visitor's window: the starts it made, and the ones still in flight. */
177
+ interface StartWindow {
178
+ windowStart: number;
179
+ count: number;
180
+ pending: number;
181
+ }
182
+
183
+ const starts = new Map<string, StartWindow>();
184
+ /** The address-less visitors' one window, which no address key can evict. */
185
+ const unknownStarts: StartWindow = { windowStart: 0, count: 0, pending: 0 };
173
186
 
174
- function visitorAddress(headers: Headers): string | null {
175
- const address =
176
- headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
177
- headers.get("x-real-ip")?.trim();
178
- return address ? address.slice(0, BUDGET_ADDRESS_LENGTH) : null;
187
+ /** Starts `window` afresh once its time is up, in place, so a start in flight settles on it. */
188
+ function rollOver(window: StartWindow, now: number): void {
189
+ if (now - window.windowStart < BUDGET_WINDOW_MS) return;
190
+ window.windowStart = now;
191
+ window.count = 0;
179
192
  }
180
193
 
181
194
  /**
182
- * Charges one start to `address`, or answers false when its window is spent.
183
- * The oldest-inserted address is evicted at BUDGET_MAX_ADDRESSES.
195
+ * The window for `address`, re-inserted when it rolls over so the
196
+ * oldest-inserted window evicted at BUDGET_MAX_ADDRESSES is the stalest.
184
197
  */
185
- function spendStart(address: string, now: number): boolean {
186
- const entry = starts.get(address);
187
- if (entry && now - entry.windowStart < BUDGET_WINDOW_MS) {
188
- if (entry.count >= BUDGET_STARTS) return false;
189
- entry.count += 1;
190
- return true;
198
+ function addressWindow(address: string, now: number): StartWindow {
199
+ const window = starts.get(address);
200
+ if (window) {
201
+ if (now - window.windowStart >= BUDGET_WINDOW_MS) {
202
+ rollOver(window, now);
203
+ starts.delete(address);
204
+ starts.set(address, window);
205
+ }
206
+ return window;
191
207
  }
192
- if (entry) {
193
- starts.delete(address);
194
- } else if (starts.size >= BUDGET_MAX_ADDRESSES) {
208
+ if (starts.size >= BUDGET_MAX_ADDRESSES) {
195
209
  const oldest = starts.keys().next().value;
196
210
  if (oldest !== undefined) starts.delete(oldest);
197
211
  }
198
- starts.set(address, { windowStart: now, count: 1 });
199
- return true;
212
+ const fresh = { windowStart: now, count: 0, pending: 0 };
213
+ starts.set(address, fresh);
214
+ return fresh;
215
+ }
216
+
217
+ /**
218
+ * Reserves one start for the visitor, or answers null when its window is
219
+ * spent (#9059). The key is `clientIpFromHeaders`'s (#9967 D1): Vercel's own
220
+ * `x-vercel-forwarded-for`, read only on Vercel, never a header the client
221
+ * chooses. A visitor with no readable address shares one window
222
+ * UNKNOWN_VISITOR_FACTOR times as wide, never skipped. Starts in flight hold
223
+ * their place, so concurrent clicks cannot overrun the window.
224
+ */
225
+ function reserveStart(headers: Headers, now: number): StartWindow | null {
226
+ const address = clientIpFromHeaders(headers);
227
+ let window = unknownStarts;
228
+ let limit = UNKNOWN_VISITOR_FACTOR * BUDGET_STARTS;
229
+ if (address === "unknown") {
230
+ rollOver(window, now);
231
+ } else {
232
+ window = addressWindow(address, now);
233
+ limit = BUDGET_STARTS;
234
+ }
235
+ if (window.count + window.pending >= limit) return null;
236
+ window.pending += 1;
237
+ return window;
238
+ }
239
+
240
+ /** Only a Checkout the API started spends the start (#9063). */
241
+ function settleStart(window: StartWindow, started: boolean): void {
242
+ window.pending -= 1;
243
+ if (started) window.count += 1;
200
244
  }
201
245
 
202
246
  // ─── D2 steps 8 and 9 — the start and its answer ────────────────────────────
@@ -263,9 +307,12 @@ async function startCheckout(
263
307
  ctx: BuyContext,
264
308
  ): Promise<Response> {
265
309
  const { lookupKey } = await ctx.params;
310
+ // #10028 D10 — a bundle link names the required product's key as `?with=`.
311
+ const withKey = new URL(request.url).searchParams.get("with");
266
312
 
267
313
  if (!isBuyerNavigation(request)) return refuse("unintended");
268
314
  if (!parseLookupKey(lookupKey)) return refuse("ungrammatical");
315
+ if (withKey !== null && !parseLookupKey(withKey)) return refuse("ungrammatical");
269
316
  if (!(await resolveCommerceClaim())) return refuse("no_claim");
270
317
  if (process.env.NODE_ENV === "production" && !configuredApiBase()) {
271
318
  return refuse("unconnected");
@@ -276,29 +323,33 @@ async function startCheckout(
276
323
  if (shown.kind !== "shown" || !isShownLookupKey(shown.shownSet, lookupKey)) {
277
324
  return refuse("not_shown");
278
325
  }
326
+ if (withKey !== null && !isShownLookupKey(shown.shownSet, withKey)) {
327
+ return refuse("not_shown");
328
+ }
279
329
 
280
330
  const origin = siteOrigin();
281
331
  if (!origin) return refuse("no_origin");
282
332
 
283
- const address = visitorAddress(request.headers);
284
- if (address && !spendStart(address, Date.now())) {
285
- return refuse("rate_limited");
286
- }
287
-
288
- const started = await settleWithin(
289
- checkout
290
- .createSession(
291
- {
292
- productCode: lookupKey,
293
- successUrl: `${origin}/purchase/success?session_id={CHECKOUT_SESSION_ID}`,
294
- cancelUrl: `${origin}/pricing`,
295
- },
296
- { headers: startHeaders(request.headers) },
297
- )
298
- .catch(() => null),
299
- START_TIMEOUT_MS,
300
- null,
301
- );
333
+ const headers = startHeaders(request.headers);
334
+ const reserved = reserveStart(request.headers, Date.now());
335
+ if (!reserved) return refuse("rate_limited");
336
+
337
+ // Settled on the API's own answer, not the timeout: a start that answers
338
+ // late still spends, and one in flight still holds its place.
339
+ const start = checkout
340
+ .createSession(
341
+ {
342
+ productCode: lookupKey,
343
+ ...(withKey !== null && { requires: withKey }),
344
+ successUrl: `${origin}/purchase/success?session_id={CHECKOUT_SESSION_ID}`,
345
+ cancelUrl: `${origin}/pricing`,
346
+ },
347
+ { headers },
348
+ )
349
+ .catch(() => null);
350
+ void start.then((answer) => settleStart(reserved, answer?.ok === true));
351
+
352
+ const started = await settleWithin(start, START_TIMEOUT_MS, null);
302
353
  if (!started) return refuse("unreachable");
303
354
  if (!started.ok) return refuse(`api_${started.status}`);
304
355
 
@@ -29,6 +29,7 @@ jest.mock("@working-theory/license", () => ({
29
29
  checkLicense: jest.fn(),
30
30
  }));
31
31
 
32
+ import { TOPOLOGY } from "../../../../../lib/topology.generated";
32
33
  import { GET } from "../[lookupKey]/route";
33
34
 
34
35
  const mockCheckLicense = checkLicense as jest.MockedFunction<
@@ -209,6 +210,7 @@ const ENV_NAMES = [
209
210
  "NODE_ENV",
210
211
  "OSS_API_INTERNAL_SECRET",
211
212
  "AILK_SPOKE_KEY",
213
+ "VERCEL",
212
214
  ] as const;
213
215
  const savedEnv = Object.fromEntries(ENV_NAMES.map((name) => [name, env[name]]));
214
216
 
@@ -248,6 +250,7 @@ beforeEach(() => {
248
250
  env.AILK_API_URL = API;
249
251
  env.NEXT_PUBLIC_BASE_URL = "https://shop.example.com";
250
252
  env.NODE_ENV = "test";
253
+ env.VERCEL = "1";
251
254
  shownAnswer = SHOWN;
252
255
  postAnswer = STARTED;
253
256
  fetchMock.mockClear();
@@ -267,8 +270,9 @@ afterEach(() => {
267
270
 
268
271
  /**
269
272
  * A buyer's card click on `key`: a top-level same-origin navigation to the
270
- * wrong host, with a session cookie and a fresh forwarded address. A header
271
- * set to `null` is left out.
273
+ * wrong host, with a session cookie and fresh forwarded addresses, Vercel's
274
+ * included, so no two cases share a visitor budget. A header set to `null` is
275
+ * left out.
272
276
  */
273
277
  function buyRequest(
274
278
  key: string,
@@ -281,6 +285,7 @@ function buyRequest(
281
285
  "sec-fetch-dest": "document",
282
286
  "sec-fetch-site": "same-origin",
283
287
  "x-forwarded-for": freshAddress(),
288
+ "x-vercel-forwarded-for": freshAddress(),
284
289
  ...options.headers,
285
290
  };
286
291
  const sent = new Headers();
@@ -411,11 +416,18 @@ describe("AC-1 — a shown key on a connected site starts its Checkout", () => {
411
416
  );
412
417
  });
413
418
 
414
- it("sends x-internal-auth from OSS_API_INTERNAL_SECRET to an https API", async () => {
415
- env.OSS_API_INTERNAL_SECRET = "s3cret";
416
- await expectStarted(await buy("growth_monthly"));
417
- expect(postHeaders(postCalls()[0]!)["x-internal-auth"]).toBe("s3cret");
418
- });
419
+ // #9601 — hub-only: a spoke render of the seam withholds the secret even
420
+ // with no spoke key, so a spoke scaffold skips this case. The sibling
421
+ // suite's "a spoke with no spoke key never sends the internal secret"
422
+ // pins the spoke side.
423
+ (TOPOLOGY === "hub" ? it : it.skip)(
424
+ "sends x-internal-auth from OSS_API_INTERNAL_SECRET to an https API",
425
+ async () => {
426
+ env.OSS_API_INTERNAL_SECRET = "s3cret";
427
+ await expectStarted(await buy("growth_monthly"));
428
+ expect(postHeaders(postCalls()[0]!)["x-internal-auth"]).toBe("s3cret");
429
+ },
430
+ );
419
431
 
420
432
  it("sends no x-internal-auth when OSS_API_INTERNAL_SECRET is unset", async () => {
421
433
  await expectStarted(await buy("growth_monthly"));
@@ -649,6 +661,46 @@ describe("AC-4 — every other outcome returns to /pricing, without throwing and
649
661
 
650
662
  // ─── AC-5 ───────────────────────────────────────────────────────────────────
651
663
 
664
+ // ── #10028 AC-10 — the bundle link's `?with=` ───────────────────────────────
665
+
666
+ describe("#10028 AC-10 — a shown ?with= key is forwarded as requires; an ungrammatical or unshown one is refused", () => {
667
+ /** A buyer's click on a bundle link: the plan key with `?with=<required>` in the URL. */
668
+ function bundleRequest(key: string, withKey: string): Request {
669
+ const base = buyRequest(key);
670
+ return new Request(`${base.url}?with=${withKey}`, { method: "GET", headers: base.headers });
671
+ }
672
+
673
+ it("sends the shown ?with= key as the body's requires, beside the plan key", async () => {
674
+ const res = await buy("growth_monthly", bundleRequest("growth_monthly", "growth_yearly"));
675
+ await expectStarted(res);
676
+ const [call] = postCalls();
677
+ expect(JSON.parse(String(call!.init.body))).toEqual({
678
+ productCode: "growth_monthly",
679
+ requires: "growth_yearly",
680
+ successUrl: "https://shop.example.com/purchase/success?session_id={CHECKOUT_SESSION_ID}",
681
+ cancelUrl: "https://shop.example.com/pricing",
682
+ });
683
+ });
684
+
685
+ it("builds exactly today's body with no ?with=", async () => {
686
+ await expectStarted(await buy("growth_monthly"));
687
+ const [call] = postCalls();
688
+ expect(JSON.parse(String(call!.init.body))).not.toHaveProperty("requires");
689
+ });
690
+
691
+ it("refuses an ungrammatical ?with= before any call", async () => {
692
+ const res = await buy("growth_monthly", bundleRequest("growth_monthly", "Growth%20Yearly"));
693
+ await expectRefused(res, "Growth Yearly", "ungrammatical");
694
+ expect(postCalls()).toHaveLength(0);
695
+ });
696
+
697
+ it("refuses a grammatical ?with= the shown set does not hold, with no POST", async () => {
698
+ const res = await buy("growth_monthly", bundleRequest("growth_monthly", "managed_monthly"));
699
+ await expectRefused(res, "managed_monthly", "not_shown");
700
+ expect(postCalls()).toHaveLength(0);
701
+ });
702
+ });
703
+
652
704
  describe("AC-5 — only a buyer's page load starts anything", () => {
653
705
  it.each<
654
706
  [string, { method?: string; headers?: Record<string, string | null> }]
@@ -735,7 +787,9 @@ describe("AC-6 — one shared read, a switched-off product stops within it, a pe
735
787
 
736
788
  it("starts at most 5 Checkouts per visitor per 10 minutes", async () => {
737
789
  const from = (address: string) =>
738
- buyRequest("growth_monthly", { headers: { "x-forwarded-for": address } });
790
+ buyRequest("growth_monthly", {
791
+ headers: { "x-vercel-forwarded-for": address },
792
+ });
739
793
 
740
794
  for (let i = 0; i < 5; i += 1) {
741
795
  await expectStarted(await buy("growth_monthly", from("192.0.2.50")));
@@ -757,43 +811,27 @@ describe("AC-6 — one shared read, a switched-off product stops within it, a pe
757
811
  expect(postCalls()).toHaveLength(7);
758
812
  });
759
813
 
760
- it("keys the budget on the first 64 characters of an address", async () => {
761
- const prefix = "f".repeat(64);
762
- for (let i = 0; i < 5; i += 1) {
763
- await expectStarted(
764
- await buy(
765
- "growth_monthly",
766
- buyRequest("growth_monthly", {
767
- headers: { "x-forwarded-for": `${prefix}${i}` },
768
- }),
769
- ),
770
- );
814
+ it("charges a request with no visitor address to one shared budget, ten times as wide", async () => {
815
+ jest.setSystemTime(Date.now() + 10 * 60_000);
816
+ const addressless = () =>
817
+ buyRequest("growth_monthly", {
818
+ headers: { "x-forwarded-for": null, "x-vercel-forwarded-for": null },
819
+ });
820
+
821
+ for (let i = 0; i < 50; i += 1) {
822
+ await expectStarted(await buy("growth_monthly", addressless()));
771
823
  }
824
+ expect(postCalls()).toHaveLength(50);
825
+
772
826
  await expectRefused(
773
- await buy(
774
- "growth_monthly",
775
- buyRequest("growth_monthly", {
776
- headers: { "x-forwarded-for": `${prefix}x` },
777
- }),
778
- ),
827
+ await buy("growth_monthly", addressless()),
779
828
  "growth_monthly",
780
829
  "rate_limited",
781
830
  );
782
- expect(postCalls()).toHaveLength(5);
783
- });
831
+ expect(postCalls()).toHaveLength(50);
784
832
 
785
- it("does not count a request with no visitor address", async () => {
786
- for (let i = 0; i < 7; i += 1) {
787
- await expectStarted(
788
- await buy(
789
- "growth_monthly",
790
- buyRequest("growth_monthly", {
791
- headers: { "x-forwarded-for": null },
792
- }),
793
- ),
794
- );
795
- }
796
- expect(postCalls()).toHaveLength(7);
833
+ await expectStarted(await buy("growth_monthly"));
834
+ expect(postCalls()).toHaveLength(51);
797
835
  });
798
836
 
799
837
  it("reaches neither Stripe nor the session cookie itself", () => {