create-ailk 0.18.0 → 0.19.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.
Files changed (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  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/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -23,6 +23,7 @@ jest.mock("../../lib/prisma.js");
23
23
 
24
24
  import { prisma } from "../../lib/prisma.js";
25
25
  import { routeLead } from "../lead-routing.js";
26
+ import * as personService from "../person.js";
26
27
  import {
27
28
  CONFIRM_TOKEN_TTL_MS,
28
29
  claimConfirmation,
@@ -218,6 +219,92 @@ describe("recordPaymentLink()", () => {
218
219
  data: { stripePaymentLinkId: "plink_abc" },
219
220
  });
220
221
  });
222
+
223
+ // ── #8435 D5 (AC-3) — the Person step, after the Payment Link exists ────
224
+ describe("links the confirmed callback to its Person", () => {
225
+ const WORKSPACE = "wksp_00000000-0000-7000-8000-0000000084e5";
226
+ const PERSON = "person_00000000-0000-7000-8000-0000000084e5";
227
+ const LINKED = {
228
+ ok: true as const,
229
+ value: { personId: PERSON as never, created: true, billing: "unchanged" as const },
230
+ };
231
+ const row = (overrides: Record<string, unknown> = {}) => ({
232
+ workspaceId: WORKSPACE,
233
+ buyerEmail: "Buyer@Example.com",
234
+ name: "Bea",
235
+ phone: "+1 415 555 0123",
236
+ personId: null,
237
+ ...overrides,
238
+ });
239
+ let find: jest.SpyInstance;
240
+
241
+ beforeEach(() => {
242
+ find = jest.spyOn(personService, "findOrCreatePerson").mockResolvedValue(LINKED);
243
+ });
244
+ afterEach(() => find.mockRestore());
245
+
246
+ it("writes the link id first, then finds the buyer email's Person in the row's own workspace and stamps it once, guarded on personId: null", async () => {
247
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(row());
248
+ await recordPaymentLink(CALLBACK_ID, "plink_abc");
249
+
250
+ expect(find).toHaveBeenCalledTimes(1);
251
+ expect(find).toHaveBeenCalledWith(WORKSPACE, {
252
+ email: "Buyer@Example.com",
253
+ name: "Bea",
254
+ contact: { telephone: "+14155550123" },
255
+ });
256
+ expect(prisma.callback.updateMany).toHaveBeenCalledTimes(1);
257
+ expect(prisma.callback.updateMany).toHaveBeenCalledWith({
258
+ where: { id: CALLBACK_ID, workspaceId: WORKSPACE, personId: null },
259
+ data: { personId: PERSON },
260
+ });
261
+ const order = (fn: jest.Mock) => fn.mock.invocationCallOrder[0]!;
262
+ expect(order(prisma.callback.update as jest.Mock)).toBeLessThan(order(prisma.callback.findFirst as jest.Mock));
263
+ });
264
+
265
+ it("makes no Person call for a callback a capture already linked", async () => {
266
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(row({ personId: PERSON }));
267
+ await recordPaymentLink(CALLBACK_ID, "plink_abc");
268
+ expect(find).not.toHaveBeenCalled();
269
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it("makes no Person call for a row with no buyer email", async () => {
273
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(row({ buyerEmail: null }));
274
+ await recordPaymentLink(CALLBACK_ID, "plink_abc");
275
+ expect(find).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it("a Person step that throws still leaves the link id written and recordPaymentLink resolving", async () => {
279
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(row());
280
+ find.mockRejectedValue(new Error("down"));
281
+ await expect(recordPaymentLink(CALLBACK_ID, "plink_abc")).resolves.toBeUndefined();
282
+ expect(prisma.callback.update).toHaveBeenCalledWith({
283
+ where: { id: CALLBACK_ID },
284
+ data: { stripePaymentLinkId: "plink_abc" },
285
+ });
286
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
287
+ });
288
+
289
+ it("a read that throws is swallowed the same way", async () => {
290
+ (prisma.callback.findFirst as jest.Mock).mockRejectedValue(new Error("down"));
291
+ await expect(recordPaymentLink(CALLBACK_ID, "plink_abc")).resolves.toBeUndefined();
292
+ });
293
+
294
+ it("claimConfirmation makes no person delegate call at all — the token is spent before any Person work", async () => {
295
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(row());
296
+ await claimConfirmation({
297
+ id: CALLBACK_ID,
298
+ token: "a-token",
299
+ confirmedTime: "Friday 3pm",
300
+ buyerEmail: "buyer@example.com",
301
+ });
302
+ expect(find).not.toHaveBeenCalled();
303
+ expect(jest.mocked(prisma.person.findFirst)).not.toHaveBeenCalled();
304
+ expect(jest.mocked(prisma.person.createMany)).not.toHaveBeenCalled();
305
+ expect(jest.mocked(prisma.person.updateMany)).not.toHaveBeenCalled();
306
+ });
307
+ });
221
308
  });
222
309
 
223
310
  // ── Configuration ──────────────────────────────────────────────────────────
@@ -12,6 +12,12 @@
12
12
  * The server binds 127.0.0.1, which `ssrfBlocked` rejects; D7's
13
13
  * `allowPrivate: true` is what makes this substrate runnable at all.
14
14
  *
15
+ * The backoff schedule is asserted through the injected `sleep` seam, never
16
+ * against wall-clock: the delays a case observes are the ones the service asked
17
+ * for, so a loaded runner cannot stretch or shrink them (#7904, #8418). And the
18
+ * server answers every request `connection: close`, so no attempt can land on
19
+ * a pooled socket the server has already dropped (#8418).
20
+ *
15
21
  * `CONTENT_REVALIDATE_*` are read through `config`, which snapshots
16
22
  * `process.env` at import time — so every case sets env first and then
17
23
  * `require`s the service through `jest.resetModules()`.
@@ -73,7 +79,6 @@ interface Recorded {
73
79
  eventHeader: string;
74
80
  timestampHeader: string;
75
81
  contentType: string;
76
- receivedAt: number;
77
82
  }
78
83
 
79
84
  let server: Server;
@@ -81,6 +86,10 @@ let serverUrl: string;
81
86
  let recorded: Recorded[] = [];
82
87
  /** Status codes the server replies with, one per request, in order. */
83
88
  let responseStatuses: number[] = [];
89
+ /** Every backoff the service asked to sleep for, in order. */
90
+ let sleeps: number[] = [];
91
+ /** The injected sleep seam: records the backoff and resolves at once. */
92
+ const sleep = async (ms: number): Promise<void> => void sleeps.push(ms);
84
93
 
85
94
  beforeAll(async () => {
86
95
  server = createServer((req, res) => {
@@ -123,10 +132,16 @@ beforeAll(async () => {
123
132
  eventHeader,
124
133
  timestampHeader,
125
134
  contentType: String(req.headers["content-type"] ?? ""),
126
- receivedAt: Date.now(),
127
135
  });
128
136
 
129
- res.writeHead(responseStatuses[recorded.length - 1] ?? 200);
137
+ // `connection: close` — one TCP connection per request. With keep-alive,
138
+ // fetch reuses a pooled socket, and when the server's idle timeout drops
139
+ // that socket as the next request goes out, the attempt fails on the
140
+ // client (UND_ERR_SOCKET) and never reaches this handler: the lost
141
+ // attempt behind #8418's flake on the #8417 release PR.
142
+ res.writeHead(responseStatuses[recorded.length - 1] ?? 200, {
143
+ connection: "close",
144
+ });
130
145
  res.end();
131
146
  });
132
147
  });
@@ -151,7 +166,14 @@ beforeEach(() => {
151
166
  process.env.CONTENT_REVALIDATE_SECRET = TEST_SECRET;
152
167
  recorded = [];
153
168
  responseStatuses = [];
169
+ sleeps = [];
154
170
  mockLogLines.length = 0;
171
+ // Drop any connection a previous case left idle — the server-side
172
+ // keep-alive close that ate attempt 1 on the CI shard, made to happen at the
173
+ // start of every case. Under `connection: close` there is never one to drop;
174
+ // without the header, the next case's first attempt lands on the dropped
175
+ // socket and a counting case below fails on every run, not 1-in-N on CI.
176
+ server.closeIdleConnections();
155
177
  });
156
178
 
157
179
  afterEach(() => {
@@ -193,35 +215,35 @@ describe("notifyRevalidate", () => {
193
215
  it("retries a 5xx with D3 backoff and succeeds on the third attempt", async () => {
194
216
  responseStatuses = [500, 500, 200];
195
217
 
196
- await loadService().notifyRevalidate(TARGET, { event: "unpublish" });
218
+ await loadService().notifyRevalidate(TARGET, {
219
+ event: "unpublish",
220
+ sleep,
221
+ });
197
222
 
198
223
  expect(recorded).toHaveLength(3);
199
- // D3 — 500 ms before attempt 2, 2000 ms before attempt 3. Bounded on BOTH
200
- // sides: a lower bound alone would pass an arbitrarily long backoff, and
201
- // the whole point of D3's schedule is that it fits the 60 s publish budget.
202
- // The upper slack absorbs loopback scheduling jitter, nothing more.
203
- const gapA = recorded[1].receivedAt - recorded[0].receivedAt;
204
- const gapB = recorded[2].receivedAt - recorded[1].receivedAt;
205
- expect(gapA).toBeGreaterThanOrEqual(500);
206
- expect(gapA).toBeLessThan(2000);
207
- expect(gapB).toBeGreaterThanOrEqual(2000);
208
- expect(gapB).toBeLessThan(4000);
224
+ // D3 — exactly 500 ms before attempt 2 and 2000 ms before attempt 3, the
225
+ // schedule that fits the 60 s publish budget. Asserted as the delays the
226
+ // service scheduled, not as measured gaps a loaded runner can stretch
227
+ // (#7904: a 2122 ms gapA failed the old `< 2000` bound).
228
+ expect(sleeps).toEqual([500, 2000]);
209
229
  expect(recorded.every((r) => r.signatureValid)).toBe(true);
210
230
 
211
231
  expect(
212
232
  mockLogLines.filter((l) => l.msg === "revalidate.sent"),
213
233
  ).toHaveLength(1);
214
234
  expect(mockLogLines.some((l) => l.level === "error")).toBe(false);
215
- }, 20_000);
235
+ });
216
236
 
217
237
  it("stops at three attempts, logs the failure at error, and still resolves", async () => {
218
238
  responseStatuses = [500, 500, 500];
219
239
 
220
240
  await expect(
221
- loadService().notifyRevalidate(TARGET, { event: "publish" }),
241
+ loadService().notifyRevalidate(TARGET, { event: "publish", sleep }),
222
242
  ).resolves.toBeUndefined();
223
243
 
224
244
  expect(recorded).toHaveLength(3);
245
+ // Two backoffs and no third: running out of the schedule IS the bound.
246
+ expect(sleeps).toEqual([500, 2000]);
225
247
 
226
248
  const perAttempt = mockLogLines.filter(
227
249
  (l) => l.level === "warn" && l.msg === "revalidate.failed",
@@ -238,7 +260,25 @@ describe("notifyRevalidate", () => {
238
260
  attempts: 3,
239
261
  lastStatus: 500,
240
262
  });
241
- }, 20_000);
263
+ });
264
+
265
+ it("waits out the backoff on the real timer when no sleep is injected", async () => {
266
+ responseStatuses = [500, 200];
267
+ const setTimeoutSpy = jest.spyOn(global, "setTimeout");
268
+ let delays: unknown[];
269
+
270
+ try {
271
+ await loadService().notifyRevalidate(TARGET, { event: "publish" });
272
+ delays = setTimeoutSpy.mock.calls.map(([, ms]) => ms);
273
+ } finally {
274
+ setTimeoutSpy.mockRestore();
275
+ }
276
+
277
+ // The production default: the one backoff is a real 500 ms timer. Asserted
278
+ // as the delay scheduled, never as a measured gap.
279
+ expect(recorded).toHaveLength(2);
280
+ expect(delays).toContain(500);
281
+ });
242
282
 
243
283
  it("signs v0:{timestamp}:{event}:{body} and sends the timestamp header (#5902)", async () => {
244
284
  responseStatuses = [200];