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
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Tests for "Email me this plan" — `saveRecommendationAction` (#4842 D7),
3
+ * split out of `checkout.actions.ts` with its cases by #6786 so the save half
4
+ * ships with the results page while checkout stays commerce's.
5
+ *
6
+ * AC-4 (the action half) — calls `flows.save` once with
7
+ * `{ answers, email, locale }` and returns the lead id.
8
+ * D7 — the shared status → kind map (`email-moment.ts`), never a throw;
9
+ * an invalid body → `invalid_input` without calling the api-client.
10
+ *
11
+ * Mock shape: `checkout.actions.test.ts`'s (`@working-theory/api-client` +
12
+ * `next/headers`).
13
+ */
14
+
15
+ import { saveRecommendationAction } from "../save.actions.js";
16
+
17
+ // #9255 — the write bot check's verdict. `checkBotId` reads Vercel's
18
+ // request context, which jest has none of, so every case decides it here.
19
+ const checkBotIdMock = jest.fn();
20
+ jest.mock("botid/server", () => ({
21
+ checkBotId: (...args: unknown[]) => checkBotIdMock(...args),
22
+ }));
23
+
24
+ jest.mock("@working-theory/api-client", () => ({
25
+ flows: {
26
+ save: jest.fn(),
27
+ },
28
+ }));
29
+
30
+ jest.mock("next/headers", () => ({
31
+ __esModule: true,
32
+ headers: jest.fn().mockResolvedValue({ get: () => null }),
33
+ }));
34
+
35
+ import { flows } from "@working-theory/api-client";
36
+ import { headers } from "next/headers";
37
+
38
+ const mockSave = flows.save as jest.Mock;
39
+ const mockHeaders = headers as jest.Mock;
40
+
41
+ const SLUG = "ailk-recommender";
42
+ const ANSWERS = { team_size: "agency", wants_seo: "yes" };
43
+ const SAVE_BODY = { answers: ANSWERS, email: "jane@example.com", locale: "en" };
44
+
45
+ beforeEach(() => {
46
+ jest.spyOn(console, "error").mockImplementation(() => undefined);
47
+ });
48
+
49
+ afterEach(() => {
50
+ jest.clearAllMocks();
51
+ jest.restoreAllMocks();
52
+ mockHeaders.mockResolvedValue({ get: () => null });
53
+ });
54
+
55
+ describe("saveRecommendationAction — AC-4 (the action half)", () => {
56
+ it("calls flows.save once with { answers, email, locale } and returns the leadId", async () => {
57
+ mockSave.mockResolvedValueOnce({
58
+ ok: true,
59
+ body: { leadId: "lead_1", sessionId: "fs_1" },
60
+ });
61
+ await expect(saveRecommendationAction(SLUG, SAVE_BODY)).resolves.toEqual({
62
+ ok: true,
63
+ leadId: "lead_1",
64
+ });
65
+ expect(mockSave).toHaveBeenCalledTimes(1);
66
+ expect(mockSave).toHaveBeenCalledWith(SLUG, SAVE_BODY);
67
+ });
68
+
69
+ it("maps failures exactly as checkout does and never throws", async () => {
70
+ mockSave.mockResolvedValueOnce({
71
+ ok: false,
72
+ status: 429,
73
+ body: { retryAfterSeconds: 9 },
74
+ });
75
+ await expect(saveRecommendationAction(SLUG, SAVE_BODY)).resolves.toEqual({
76
+ ok: false,
77
+ kind: "rate_limited",
78
+ retryAfterSeconds: 9,
79
+ });
80
+ mockSave.mockRejectedValueOnce(new Error("boom"));
81
+ await expect(saveRecommendationAction(SLUG, SAVE_BODY)).resolves.toEqual({
82
+ ok: false,
83
+ kind: "network",
84
+ });
85
+ await expect(
86
+ saveRecommendationAction(SLUG, { ...SAVE_BODY, email: "nope" }),
87
+ ).resolves.toEqual({ ok: false, kind: "invalid_input" });
88
+ expect(mockSave).toHaveBeenCalledTimes(2);
89
+ });
90
+
91
+ it("forwards the visitor's x-forwarded-for on the outbound call (#4730)", async () => {
92
+ mockHeaders.mockResolvedValueOnce({
93
+ get: (name: string) =>
94
+ name === "x-forwarded-for" ? "203.0.113.7" : null,
95
+ });
96
+ mockSave.mockResolvedValueOnce({
97
+ ok: true,
98
+ body: { leadId: "lead_1", sessionId: "fs_1" },
99
+ });
100
+ await saveRecommendationAction(SLUG, SAVE_BODY);
101
+ expect(mockSave).toHaveBeenCalledWith(SLUG, SAVE_BODY, {
102
+ headers: { "x-forwarded-for": "203.0.113.7" },
103
+ });
104
+ });
105
+ });
106
+
107
+ // #9255 D4 — the write bot check runs after the slug and body checks,
108
+ // immediately before the write (spec
109
+ // docs/specs/9255-an-edge-bot-challenge-in-spec.md §5.1 AC-2).
110
+ describe("saveRecommendationAction — the write bot check (#9255)", () => {
111
+ const BOT = {
112
+ isHuman: false,
113
+ isBot: true,
114
+ isVerifiedBot: false,
115
+ bypassed: false,
116
+ };
117
+ const HUMAN = { ...BOT, isHuman: true, isBot: false };
118
+ const BOT_ENV_KEYS = [
119
+ "VERCEL",
120
+ "AILK_BOT_CHECK",
121
+ "AILK_BOOKING_BOT_CHECK",
122
+ ] as const;
123
+ const priorBotEnv = new Map(
124
+ BOT_ENV_KEYS.map((name) => [name, process.env[name]]),
125
+ );
126
+
127
+ let botLogSpies: jest.SpyInstance[] = [];
128
+
129
+ beforeEach(() => {
130
+ checkBotIdMock.mockReset();
131
+ for (const name of BOT_ENV_KEYS) delete process.env[name];
132
+ process.env.VERCEL = "1";
133
+ botLogSpies = [
134
+ jest.spyOn(console, "warn").mockImplementation(() => undefined),
135
+ jest.spyOn(console, "error").mockImplementation(() => undefined),
136
+ ];
137
+ });
138
+
139
+ afterEach(() => {
140
+ for (const [name, value] of priorBotEnv) {
141
+ if (value === undefined) delete process.env[name];
142
+ else process.env[name] = value;
143
+ }
144
+ for (const spy of botLogSpies) spy.mockRestore();
145
+ });
146
+
147
+ it.each([
148
+ ["a bot", () => checkBotIdMock.mockResolvedValue(BOT)],
149
+ [
150
+ "a check that fails",
151
+ () => checkBotIdMock.mockRejectedValue(new Error("boom")),
152
+ ],
153
+ ])(
154
+ "%s gets kind unavailable, and nothing is sent",
155
+ async (_label, decide) => {
156
+ decide();
157
+ expect(await saveRecommendationAction(SLUG, SAVE_BODY)).toStrictEqual({
158
+ ok: false,
159
+ kind: "unavailable",
160
+ });
161
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
162
+ expect(mockSave).not.toHaveBeenCalled();
163
+ },
164
+ );
165
+
166
+ it("a human sends exactly what the check off sends", async () => {
167
+ checkBotIdMock.mockResolvedValue(HUMAN);
168
+ mockSave.mockResolvedValue({
169
+ ok: true,
170
+ status: 201,
171
+ body: { leadId: "lead_1" },
172
+ });
173
+ delete process.env.VERCEL;
174
+ await saveRecommendationAction(SLUG, SAVE_BODY);
175
+ process.env.VERCEL = "1";
176
+ expect(await saveRecommendationAction(SLUG, SAVE_BODY)).toEqual({
177
+ ok: true,
178
+ leadId: "lead_1",
179
+ });
180
+ expect(checkBotIdMock).toHaveBeenCalledTimes(1);
181
+ expect(mockSave).toHaveBeenCalledTimes(2);
182
+ expect(mockSave.mock.calls[1]).toEqual(mockSave.mock.calls[0]);
183
+ });
184
+
185
+ it("an invalid slug answers invalid_input with no check", async () => {
186
+ checkBotIdMock.mockResolvedValue(BOT);
187
+ expect(await saveRecommendationAction("Not A Slug", SAVE_BODY)).toEqual({
188
+ ok: false,
189
+ kind: "invalid_input",
190
+ });
191
+ expect(checkBotIdMock).not.toHaveBeenCalled();
192
+ });
193
+ });
@@ -0,0 +1,92 @@
1
+ // oss: false
2
+ /**
3
+ * email-moment.ts — the helpers the recommender's two email-moment Server
4
+ * Actions share (#4842 D7): `saveRecommendationAction` (`save.actions.ts`,
5
+ * shipped with the results page) and `startCheckoutAction`
6
+ * (`checkout.actions.ts`, commerce's). Split out of `checkout.actions.ts` by
7
+ * #6786 so the save half ships without the checkout half; a `"use server"`
8
+ * file may export only async functions, so the shared sync helpers live here.
9
+ *
10
+ * Both actions mirror `submitFlowStepAction`'s idiom exactly: the visitor-IP
11
+ * forwarding helper (duplicated here — the `flow.actions.ts` original is
12
+ * module-private and that file is this unit's regression floor), a status →
13
+ * kind map, and NEVER a throw to the client. Logging is PII-safe: slug + kind
14
+ * only — the email is never logged.
15
+ */
16
+ import type { FlowSaveInput } from "@working-theory/validation";
17
+ import { headers } from "next/headers";
18
+
19
+ export type EmailMomentFailureKind =
20
+ | "invalid_input"
21
+ | "not_configured"
22
+ | "rate_limited"
23
+ | "unavailable"
24
+ | "network";
25
+
26
+ export interface EmailMomentFailure {
27
+ ok: false;
28
+ kind: EmailMomentFailureKind;
29
+ retryAfterSeconds?: number;
30
+ }
31
+
32
+ /**
33
+ * Reads the visitor's real IP from THIS action's own incoming request —
34
+ * never from the browser-supplied payload — preferring `x-forwarded-for`
35
+ * and falling back to `x-real-ip`. Returns `undefined` rather than throwing
36
+ * on any failure (a local copy of `flow.actions.ts`'s helper, #4730).
37
+ */
38
+ export async function resolveVisitorIpInit(): Promise<
39
+ { headers: Record<string, string> } | undefined
40
+ > {
41
+ try {
42
+ const incoming = await headers();
43
+ const visitorIp =
44
+ incoming.get("x-forwarded-for") ?? incoming.get("x-real-ip");
45
+ return visitorIp
46
+ ? { headers: { "x-forwarded-for": visitorIp } }
47
+ : undefined;
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /** PII-safe error identity for logs — the `recommender.ts` idiom (`err.name` only). */
54
+ export function errorName(err: unknown): string {
55
+ return err instanceof Error ? err.name : "unknown";
56
+ }
57
+
58
+ /** The shared `status → kind` map (D7). Never throws. */
59
+ export function failureFor(res: {
60
+ status: number;
61
+ body: unknown;
62
+ }): EmailMomentFailure {
63
+ if (res.status === 400) return { ok: false, kind: "invalid_input" };
64
+ if (res.status === 409) return { ok: false, kind: "not_configured" };
65
+ if (res.status === 429) {
66
+ const body = res.body as { retryAfterSeconds?: number } | null;
67
+ return {
68
+ ok: false,
69
+ kind: "rate_limited",
70
+ ...(typeof body?.retryAfterSeconds === "number"
71
+ ? { retryAfterSeconds: body.retryAfterSeconds }
72
+ : {}),
73
+ };
74
+ }
75
+ return { ok: false, kind: "unavailable" };
76
+ }
77
+
78
+ /**
79
+ * exactOptionalPropertyTypes-safe rebuild of the parsed body: Zod's inferred
80
+ * optional fields type as `T | undefined`, which does not structurally match
81
+ * the api-client's `sessionId?: string` / `locale?: string` — so an absent
82
+ * optional key is OMITTED rather than sent as an explicit `undefined`.
83
+ */
84
+ export function emailMomentFields(parsed: FlowSaveInput): FlowSaveInput {
85
+ const { answers, email, locale, sessionId } = parsed;
86
+ return {
87
+ answers,
88
+ email,
89
+ ...(locale !== undefined ? { locale } : {}),
90
+ ...(sessionId !== undefined ? { sessionId } : {}),
91
+ };
92
+ }
@@ -37,6 +37,7 @@ import {
37
37
  } from "@working-theory/validation";
38
38
  import { cookies, headers } from "next/headers";
39
39
 
40
+ import { runBotCheck } from "../../../../lib/bot-check";
40
41
  import { consentVisitorHeader, readConsentVisitor } from "../../../../lib/consent-visitor";
41
42
 
42
43
  /**
@@ -106,6 +107,15 @@ export async function submitFlowStepAction(
106
107
  };
107
108
  }
108
109
 
110
+ // #9255 D4 — the spoke edge's bot check, after the input checks and before
111
+ // the write. A refusal is the walk's existing "try again" variant.
112
+ const botCheck = await runBotCheck({
113
+ source: "bot-check",
114
+ target: "submitFlowStepAction",
115
+ scope: "write",
116
+ });
117
+ if (botCheck !== "pass") return { ok: false, kind: "network" };
118
+
109
119
  try {
110
120
  // exactOptionalPropertyTypes-safe: submitFlowStepInputSchema's Zod-inferred
111
121
  // optional fields type as `T | undefined`, which does not structurally