create-ailk 0.22.0 → 0.23.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 (238) hide show
  1. package/component-catalog.json +149 -5
  2. package/component-catalog.md +7 -6
  3. package/dist/add-module.js +11 -9
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +7 -5
  8. package/dist/derive-architecture.js +9 -32
  9. package/dist/lib/design-checks.js +4 -2
  10. package/dist/lib/env-catalog.js +0 -4
  11. package/dist/lib/npm-auth.d.ts +9 -0
  12. package/dist/lib/npm-auth.js +48 -0
  13. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  14. package/dist/lib/scaffold-render-sweep.js +5 -8
  15. package/dist/module-architecture.js +98 -10
  16. package/dist/paid-sections.d.ts +21 -2
  17. package/dist/paid-sections.js +69 -7
  18. package/dist/seed-flows.d.ts +4 -1
  19. package/dist/seed-flows.js +50 -4
  20. package/dist/spoke-checkout.d.ts +38 -35
  21. package/dist/spoke-checkout.js +66 -229
  22. package/dist/starter-grid.d.ts +0 -3
  23. package/dist/starter-grid.js +24 -14
  24. package/dist/surfaces.js +33 -12
  25. package/dist/sync-routes.d.ts +38 -1
  26. package/dist/sync-routes.js +184 -11
  27. package/dist/template-record.d.ts +1 -1
  28. package/dist/template-record.js +3 -2
  29. package/dist/update.js +10 -0
  30. package/package.json +3 -3
  31. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  32. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  35. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  37. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  38. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  40. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  41. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  43. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/templates/.env.example +9 -7
  48. package/templates/.github/workflows/ci.yml +13 -0
  49. package/templates/README.md +1 -0
  50. package/templates/_gitignore +4 -0
  51. package/templates/apps/api/.env.example +27 -0
  52. package/templates/apps/api/CLAUDE.md +77 -16
  53. package/templates/apps/api/package.json +1 -0
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  55. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  56. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  57. package/templates/apps/api/src/config/index.ts +109 -0
  58. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  59. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  60. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  61. package/templates/apps/api/src/lib/stripe.ts +74 -4
  62. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  63. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  64. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  65. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  66. package/templates/apps/api/src/openapi/spec.ts +10 -7
  67. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  68. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  69. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  70. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  71. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  72. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  73. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  74. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  75. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  76. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  77. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  78. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  79. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  80. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  81. package/templates/apps/api/src/routes/content/index.ts +16 -2
  82. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  83. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  84. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  85. package/templates/apps/api/src/routes/leads/index.ts +17 -14
  86. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  87. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  88. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  89. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  90. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  91. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  92. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  93. package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
  94. package/templates/apps/api/src/server.ts +140 -51
  95. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  96. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  97. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  98. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  99. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  100. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  101. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  102. package/templates/apps/api/src/services/entitlements.ts +157 -1
  103. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  104. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  105. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  106. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  107. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  108. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  109. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  110. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  111. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  112. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  113. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  114. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  115. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  116. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  117. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  118. package/templates/apps/api/src/services/person.ts +124 -9
  119. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  120. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  121. package/templates/apps/mcp/CLAUDE.md +22 -14
  122. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  123. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  124. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  125. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  126. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  127. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  128. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  129. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  130. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  131. package/templates/apps/mcp/src/server.ts +5 -0
  132. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  133. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  134. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  135. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  136. package/templates/apps/web/.env.example +10 -31
  137. package/templates/apps/web/CLAUDE.md +315 -42
  138. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  139. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  140. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  141. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  142. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  143. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  144. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  145. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  146. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  147. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  148. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  149. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  150. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  151. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  152. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  153. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  154. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
  155. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  156. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  157. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  158. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  159. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  160. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  161. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  162. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  163. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  164. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  165. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  166. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  167. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  168. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  169. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  170. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  171. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  172. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  173. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  174. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  175. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  176. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  177. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  178. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  179. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  180. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  181. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  182. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  183. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  184. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  185. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  187. package/templates/apps/web/lib/auth-routes.ts +36 -2
  188. package/templates/apps/web/lib/checkout-config.ts +9 -20
  189. package/templates/apps/web/lib/deck-page.ts +1 -0
  190. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  191. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  192. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  195. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  196. package/templates/apps/web/lib/posting-title.ts +17 -0
  197. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  198. package/templates/apps/web/messages/en.json +49 -15
  199. package/templates/apps/web/middleware.ts +12 -2
  200. package/templates/apps/web/security-headers.config.ts +17 -0
  201. package/templates/content/en/BlogList/blog.mdx +4 -71
  202. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  203. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  204. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  205. package/templates/content/en/BookCall/start-project.mdx +1 -17
  206. package/templates/content/en/ContactPage/contact.mdx +6 -63
  207. package/templates/content/en/HomePage/home.mdx +3 -21
  208. package/templates/database/CHANGELOG.md +31 -0
  209. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  210. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  211. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  212. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  213. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  214. package/templates/database/__tests__/seed.test.ts +31 -1
  215. package/templates/database/auth/schema.prisma +4 -0
  216. package/templates/database/config/schema.prisma +91 -0
  217. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  218. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  219. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  220. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  221. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  222. package/templates/database/package.json +1 -1
  223. package/templates/database/seed.ts +30 -2
  224. package/versions.json +35 -35
  225. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  226. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  227. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  228. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  229. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  230. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  231. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  232. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  233. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  234. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  235. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  236. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  237. package/templates/apps/web/lib/checkout.ts +0 -93
  238. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,416 @@
1
+ /**
2
+ * in-page-magnet.ts — the in-page form shape (#8847 D6, D15).
3
+ *
4
+ * A contact, quote or newsletter magnet renders from its hub record inside a
5
+ * chrome-on page: the page holds layout only, and every visible fact on the
6
+ * form (heading, sentence, fields, consent, button, ending) comes from the
7
+ * magnet's served config. /contact, /newsletter, /unsubscribe and the
8
+ * footer signup all read through here.
9
+ *
10
+ * - `sharedHubRead` shares a hub read across requests for HUB_READ_TTL_MS
11
+ * (#8731, moved here from /contact): a visitor view carries no forwarding
12
+ * headers, so on a spoke every view would otherwise count against the
13
+ * spoke server's one per-IP limit at the hub. An Admin edit shows within
14
+ * that window.
15
+ * - `readServedMagnet(slug)` is the public flow read; any throw or non-2xx
16
+ * (absent, draft, a Free build with no flow route) is `null`.
17
+ * - `inPageView(config, locale, form)` maps a config's one field step. The
18
+ * form kind comes from the CALLER, never the config or the slug (C1): a
19
+ * served config carries no `kind`.
20
+ * - `framedSources` makes the page's first `contact-form` section carry the
21
+ * magnet's title and body, and — when that section leads the page — the
22
+ * frontmatter title too, so `renderPage`'s title carve-out gives the
23
+ * section the `<h1>` and `renderPageMetadata` gives `<title>` the same text.
24
+ * - `renderInPageMagnet` is the slotted surface for a slug (D15): the form
25
+ * bound to its action, or `ContactFallback` when capture is off.
26
+ *
27
+ * Pure apart from the two reads; no React state.
28
+ */
29
+ import { flows, leads } from "@working-theory/api-client";
30
+ import type {
31
+ getMdxSource as defaultGetMdxSource,
32
+ getPageSections as defaultGetPageSections,
33
+ } from "@working-theory/page-renderer";
34
+ import {
35
+ ContactFallback,
36
+ LeadCaptureForm,
37
+ type LeadCaptureFormText,
38
+ localeHref,
39
+ NewsletterFormInline,
40
+ } from "@working-theory/ui";
41
+ import {
42
+ flowLeadSource,
43
+ flowSitePathSchema,
44
+ resolveLocalizedString,
45
+ type FlowConfig,
46
+ } from "@working-theory/validation";
47
+ import { createElement, type ReactElement } from "react";
48
+
49
+ import {
50
+ contactFormFromConfig,
51
+ type ContactFormView,
52
+ } from "../app/[locale]/contact/contact-form-magnet";
53
+ import { captureLeadAction } from "../app/[locale]/contact/contact.actions";
54
+
55
+ import { consentPoliciesFor } from "./consent-policies";
56
+ import { subscribeNewsletterAction } from "./newsletter.actions";
57
+
58
+
59
+ export { fillEndingName } from "@working-theory/validation";
60
+
61
+ // ─── Shared hub reads (#8731) ──────────────────────────────────────────────────
62
+
63
+ /**
64
+ * How long a hub read is shared across requests (#8731). Short, so an Admin
65
+ * edit to the magnet shows within seconds; long enough that a burst of views
66
+ * makes one read of each, not one per view.
67
+ */
68
+ export const HUB_READ_TTL_MS = 15_000;
69
+
70
+ const hubReads = new Map<string, { at: number; read: Promise<unknown> }>();
71
+
72
+ /**
73
+ * How long a page waits on one hub read before treating it as failed. The
74
+ * root layout reads the newsletter magnet for the footer on every page, so a
75
+ * hub that accepts the connection and stalls must not hold the site.
76
+ */
77
+ export const HUB_READ_DEADLINE_MS = 2_000;
78
+
79
+ /** `read`'s result, or `fallback` once HUB_READ_DEADLINE_MS has passed. */
80
+ function withDeadline<T>(read: Promise<T>, fallback: T): Promise<T> {
81
+ let timer: ReturnType<typeof setTimeout> | undefined;
82
+ const late = new Promise<T>((resolve) => {
83
+ timer = setTimeout(() => resolve(fallback), HUB_READ_DEADLINE_MS);
84
+ });
85
+ return Promise.race([read, late]).finally(() => clearTimeout(timer));
86
+ }
87
+
88
+ /**
89
+ * `read()`'s result, shared per instance for HUB_READ_TTL_MS. Concurrent calls
90
+ * share one in-flight read. A read that settles to `null` (any failed read,
91
+ * see the readers below) is shared the same way, so a burst against an
92
+ * unreachable hub is not a burst of retries.
93
+ */
94
+ export function sharedHubRead<T>(key: string, read: () => Promise<T>): Promise<T> {
95
+ const now = Date.now();
96
+ const hit = hubReads.get(key);
97
+ if (hit && now - hit.at < HUB_READ_TTL_MS) return hit.read as Promise<T>;
98
+ const fresh = read();
99
+ hubReads.set(key, { at: now, read: fresh });
100
+ return fresh;
101
+ }
102
+
103
+ export type CaptureStatus = Extract<
104
+ Awaited<ReturnType<typeof leads.captureStatus>>,
105
+ { ok: true }
106
+ >["body"]["data"];
107
+
108
+ /** The API's capture status, or `null` for any failed read. */
109
+ export function readCaptureStatus(): Promise<CaptureStatus | null> {
110
+ return sharedHubRead("capture-status", () =>
111
+ withDeadline(
112
+ (async () => {
113
+ try {
114
+ const res = await leads.captureStatus();
115
+ return res.ok && res.body.ok === true ? res.body.data : null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ })(),
120
+ null,
121
+ ),
122
+ );
123
+ }
124
+
125
+ /**
126
+ * The served magnet's config at `slug`, or `null` for any failed read: a
127
+ * non-2xx (absent, draft, a build with no flow route), a throw, or no answer
128
+ * within HUB_READ_DEADLINE_MS. The
129
+ * property access sits inside the `try`, so a client with no `flows` is a
130
+ * failed read too.
131
+ */
132
+ export function readServedMagnet(slug: string): Promise<FlowConfig | null> {
133
+ return sharedHubRead(`magnet:${slug}`, () =>
134
+ withDeadline(
135
+ (async () => {
136
+ try {
137
+ const res = await flows.get(slug);
138
+ return res.ok ? res.body : null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ })(),
143
+ null,
144
+ ),
145
+ );
146
+ }
147
+
148
+ // ─── The view ──────────────────────────────────────────────────────────────────
149
+
150
+ /** Which form a caller asks for (C1). */
151
+ export type InPageForm = "contact" | "newsletter";
152
+
153
+ /** The magnet's `ending.default`, resolved and localized (#8841 D1 shape). */
154
+ export type InPageEnding =
155
+ | {
156
+ kind: "screen";
157
+ headline: string;
158
+ body?: string;
159
+ ctas?: { label: string; href: string }[];
160
+ }
161
+ | { kind: "redirect"; href: string };
162
+
163
+ /** A newsletter magnet's one step, as `NewsletterFormInline` renders it. */
164
+ export interface NewsletterFormView {
165
+ kind: "newsletter";
166
+ /** The `email` field's name — the key the step submit sends the address under. */
167
+ emailField: string;
168
+ label: string;
169
+ placeholder?: string;
170
+ /** The step's first consent field: its name, purposes, statement and help line. */
171
+ consent?: { field: string; purposes: string[]; statement: string; help?: string };
172
+ submitLabel?: string;
173
+ }
174
+
175
+ export interface InPageView {
176
+ stepKey: string;
177
+ title: string;
178
+ body?: string;
179
+ /** Absent when the config has no `ending` and the caller gave no fallback line. */
180
+ ending?: InPageEnding;
181
+ form: ContactFormView | NewsletterFormView;
182
+ }
183
+
184
+ type FieldStep = Extract<FlowConfig["steps"][number], { type: "field" }>;
185
+
186
+ /** The field types a visitor types or picks a value into. */
187
+ const ENTRY_TYPES = new Set(["text", "email", "tel", "url", "textarea", "select", "address"]);
188
+
189
+ function newsletterForm(
190
+ step: FieldStep,
191
+ resolve: (value: Record<string, string>) => string,
192
+ ): NewsletterFormView | null {
193
+ const email = step.fields.find((f) => f.inputType === "email" && f.mapsTo === "email");
194
+ if (email === undefined) return null;
195
+ // Any other field a visitor fills in is not a one-field signup.
196
+ if (step.fields.some((f) => f !== email && ENTRY_TYPES.has(f.inputType))) return null;
197
+ const consent = step.fields.find((f) => f.inputType === "consent");
198
+ return {
199
+ kind: "newsletter",
200
+ emailField: email.name,
201
+ label: resolve(email.copy.label),
202
+ ...(email.copy.placeholder !== undefined && { placeholder: resolve(email.copy.placeholder) }),
203
+ ...(consent !== undefined && {
204
+ consent: {
205
+ field: consent.name,
206
+ purposes: consent.purposes ?? [],
207
+ statement: resolve(consent.copy.label),
208
+ ...(consent.copy.help !== undefined && { help: resolve(consent.copy.help) }),
209
+ },
210
+ }),
211
+ ...(step.copy.cta !== undefined && { submitLabel: resolve(step.copy.cta) }),
212
+ };
213
+ }
214
+
215
+ /**
216
+ * A config's one field step as an in-page form of the kind the caller names,
217
+ * or `null` when it is not one: its first step is not a `field` step, a
218
+ * newsletter config has more than one step or a field other than its email
219
+ * and consent, or a contact config lacks `name` and `email`. A config with no
220
+ * `ending` ends on `fallbackHeadline` (the page's catalog success line), or
221
+ * has no `ending` when the caller gives none, so the form keeps its own.
222
+ */
223
+ export function inPageView(
224
+ config: FlowConfig,
225
+ locale: string,
226
+ form: InPageForm,
227
+ fallbackHeadline?: string,
228
+ ): InPageView | null {
229
+ const step = config.steps[0];
230
+ if (step?.type !== "field") return null;
231
+ const resolve = (value: Record<string, string>): string =>
232
+ resolveLocalizedString(value, locale, config.defaultLocale);
233
+
234
+ let view: ContactFormView | NewsletterFormView | null;
235
+ if (form === "newsletter") {
236
+ view = config.steps.length === 1 ? newsletterForm(step, resolve) : null;
237
+ } else {
238
+ view = contactFormFromConfig(config, locale);
239
+ }
240
+ if (view === null) return null;
241
+
242
+ // Every destination is re-checked against the site-path grammar before it
243
+ // is rendered or navigated to, as the walk does (#8840): a stored row that
244
+ // bypassed the write-side check never becomes an off-site link.
245
+ const onSite = (href: string): boolean => flowSitePathSchema.safeParse(href).success;
246
+ const outcome = config.ending?.default;
247
+ const fallback: InPageEnding | undefined =
248
+ fallbackHeadline === undefined ? undefined : { kind: "screen", headline: fallbackHeadline };
249
+ let ending: InPageEnding | undefined;
250
+ if (outcome === undefined) {
251
+ ending = fallback;
252
+ } else if (outcome.kind === "redirect") {
253
+ ending = onSite(outcome.href) ? { kind: "redirect", href: localeHref(locale, outcome.href) } : fallback;
254
+ } else {
255
+ const ctas = (outcome.ctas ?? []).filter((cta) => onSite(cta.href));
256
+ ending = {
257
+ kind: "screen",
258
+ headline: resolve(outcome.headline),
259
+ ...(outcome.body !== undefined && { body: resolve(outcome.body) }),
260
+ ...(ctas.length > 0 && {
261
+ ctas: ctas.map((cta) => ({
262
+ label: resolve(cta.label),
263
+ href: localeHref(locale, cta.href),
264
+ })),
265
+ }),
266
+ };
267
+ }
268
+
269
+ return {
270
+ stepKey: step.key,
271
+ title: resolve(step.copy.title),
272
+ ...(step.copy.body !== undefined && { body: resolve(step.copy.body) }),
273
+ ...(ending !== undefined && { ending }),
274
+ form: view,
275
+ };
276
+ }
277
+
278
+ // ─── The page frame ────────────────────────────────────────────────────────────
279
+
280
+ type Loaders = {
281
+ getMdxSource: typeof defaultGetMdxSource;
282
+ getPageSections: typeof defaultGetPageSections;
283
+ };
284
+ type Section = Awaited<ReturnType<typeof defaultGetPageSections>>[number];
285
+
286
+ /**
287
+ * The page's first `contact-form` section takes `headline = view.title` and
288
+ * `description = view.body` (deleted when there is none). When that section
289
+ * is the first non-alert section, the frontmatter `title` is `view.title`
290
+ * too. With no `contact-form` section, both loaders pass through unchanged.
291
+ */
292
+ export function framedSources({
293
+ getMdxSource,
294
+ getPageSections,
295
+ view,
296
+ }: Loaders & { view: Pick<InPageView, "title" | "body"> }): Loaders {
297
+ const framedSections = async (frontmatter: unknown): Promise<Section[]> => {
298
+ const sections = await getPageSections(frontmatter);
299
+ const at = sections.findIndex((s) => s.type === "contact-form");
300
+ if (at < 0) return sections;
301
+ return sections.map((section, i) => {
302
+ if (i !== at || section.type !== "contact-form") return section;
303
+ const data = { ...section.data, headline: view.title };
304
+ if (view.body !== undefined) data.description = view.body;
305
+ else delete data.description;
306
+ return { ...section, data };
307
+ });
308
+ };
309
+ const framedSource: typeof defaultGetMdxSource = async (type, slug, locale) => {
310
+ const source = await getMdxSource(type, slug, locale);
311
+ if (source === null) return source;
312
+ const sections = await getPageSections(source.frontmatter);
313
+ const leading = sections.find((s) => s.type !== "alert");
314
+ if (leading?.type !== "contact-form") return source;
315
+ return { ...source, frontmatter: { ...source.frontmatter, title: view.title } };
316
+ };
317
+ return { getMdxSource: framedSource, getPageSections: framedSections };
318
+ }
319
+
320
+ // ─── The slotted surface (D15) ─────────────────────────────────────────────────
321
+
322
+ export interface NewsletterSignupOptions {
323
+ locale: string;
324
+ fallbackEmail?: string;
325
+ /** The footer's compact form: stacked, the label `sr-only`, the placeholder naming the field. */
326
+ compact?: boolean;
327
+ className?: string;
328
+ }
329
+
330
+ /**
331
+ * The newsletter magnet's signup: the standard `NewsletterFormInline` bound to
332
+ * `subscribeNewsletterAction`, every word from the view. On /newsletter the
333
+ * email label is visible and the field and Subscribe share a row from `md`;
334
+ * the footer's compact form stacks, with the label `sr-only`.
335
+ */
336
+ export function newsletterSignup(
337
+ view: InPageView & { form: NewsletterFormView },
338
+ { locale, fallbackEmail, compact = false, className }: NewsletterSignupOptions,
339
+ ): ReactElement {
340
+ const { form, ending } = view;
341
+ const placeholder = form.placeholder ?? (compact ? form.label : undefined);
342
+ return createElement(NewsletterFormInline, {
343
+ formAction: subscribeNewsletterAction,
344
+ emailLabel: form.label,
345
+ ...(compact ? { layout: "stacked" as const } : { label: form.label }),
346
+ ...(placeholder !== undefined && { placeholder }),
347
+ ...(form.consent !== undefined && {
348
+ consentStatement: form.consent.statement,
349
+ policies: consentPoliciesFor(locale),
350
+ ...(form.consent.help !== undefined && { helpText: form.consent.help }),
351
+ }),
352
+ ...(form.submitLabel !== undefined && { submitLabel: form.submitLabel }),
353
+ // A redirect ending has no in-place form; the block's own success line stands.
354
+ ...(ending?.kind === "screen" && {
355
+ ending: { headline: ending.headline, ...(ending.body !== undefined && { body: ending.body }) },
356
+ }),
357
+ ...(fallbackEmail !== undefined && { fallbackEmail }),
358
+ ...(className !== undefined && { className }),
359
+ });
360
+ }
361
+
362
+ export interface RenderInPageMagnetInput {
363
+ slug: string;
364
+ locale: string;
365
+ /** The page's view of the magnet (`inPageView`), mapped for the caller's form. */
366
+ view: InPageView;
367
+ /** The `contact` catalog's form copy (`contactFormText`). */
368
+ text: LeadCaptureFormText;
369
+ accepting: boolean;
370
+ fallbackEmail?: string;
371
+ }
372
+
373
+ /**
374
+ * The surface a page slots for an in-page magnet: the form bound to its
375
+ * action, or `ContactFallback` when capture is off. A `contact` form posts
376
+ * through `captureLeadAction` stamped `flowLeadSource(slug)`; the action keeps
377
+ * only the contact sources (`CONTACT_LEAD_SOURCES`), so a quote magnet's post
378
+ * lands as `contact-form` until that list admits its slug. A `newsletter`
379
+ * form posts through `subscribeNewsletterAction`.
380
+ */
381
+ export function renderInPageMagnet({
382
+ slug,
383
+ locale,
384
+ view,
385
+ text,
386
+ accepting,
387
+ fallbackEmail,
388
+ }: RenderInPageMagnetInput): ReactElement {
389
+ if (!accepting) {
390
+ return createElement(ContactFallback, {
391
+ email: fallbackEmail,
392
+ text: {
393
+ heading: text.unavailableHeading,
394
+ body: text.unavailableBody,
395
+ noEmail: text.unavailableNoEmail,
396
+ },
397
+ });
398
+ }
399
+ const form = view.form;
400
+ if ("kind" in form) {
401
+ return newsletterSignup({ ...view, form }, { locale, ...(fallbackEmail !== undefined && { fallbackEmail }) });
402
+ }
403
+ return createElement(LeadCaptureForm, {
404
+ action: captureLeadAction,
405
+ text: form.consentStatement ? { ...text, consentStatement: form.consentStatement } : text,
406
+ termsHref: localeHref(locale, "/terms"),
407
+ policies: consentPoliciesFor(locale),
408
+ fallbackEmail,
409
+ fields: form.fields,
410
+ submitLabel: form.submitLabel,
411
+ ending: view.ending,
412
+ source: flowLeadSource(slug),
413
+ look: "column",
414
+ consentGate: "on-submit",
415
+ });
416
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The magnet walk's path predicate (#8840 D10, spec
3
+ * docs/specs/8840-a-default-full-screen-magnet-spec.md).
4
+ *
5
+ * A magnet's walk renders full-screen: `app/[locale]/layout.tsx` drops the
6
+ * Nav and the Footer for it, as it does for the auth and account paths, and
7
+ * keeps the cookie consent banner (a visitor can arrive straight at a walk).
8
+ * A walk is `/<magnet>/start` — any path whose last segment is `start` — or
9
+ * one of the two careers apply walks, `/careers/apply` and
10
+ * `/careers/<posting>/apply`, each bare or under an enabled-locale prefix
11
+ * (the `auth-routes.ts` rule). #8843's meeting pages add their paths here
12
+ * rather than writing a second predicate. #8848 D9 adds every page under
13
+ * `/reports/<slug>` — a gated landing, an ungated reader and the grant
14
+ * reader alike, since the path cannot tell them apart; `/reports` itself
15
+ * keeps its chrome.
16
+ *
17
+ * Fail-safe direction, as `isAuthPath()`: an absent or empty pathname
18
+ * answers `false`, so a missing `x-pathname` never strips a page's chrome.
19
+ *
20
+ * Core, not a module slice: the layout every scaffold carries imports it, and
21
+ * it names no paid code. No Next.js import — a pure function.
22
+ */
23
+ export function isMagnetWalkPath(
24
+ pathname: string | undefined | null,
25
+ locales: readonly string[],
26
+ ): boolean {
27
+ if (!pathname) return false;
28
+ const path = pathname.split(/[?#]/)[0] ?? "";
29
+ const bare =
30
+ locales.length > 0
31
+ ? path.replace(new RegExp(`^/(${locales.join("|")})(?=/|$)`), "")
32
+ : path;
33
+ const segments = bare.split("/").filter((segment) => segment.length > 0);
34
+ if (segments.length === 0) return false;
35
+ if (segments[segments.length - 1] === "start") return true;
36
+ if (segments[0] === "reports" && segments.length === 2) return true;
37
+ return (
38
+ segments[0] === "careers" &&
39
+ segments[segments.length - 1] === "apply" &&
40
+ (segments.length === 2 || segments.length === 3)
41
+ );
42
+ }
@@ -0,0 +1,9 @@
1
+ // modules.generated.ts — which module pages this site carries.
2
+ //
3
+ // GENERATED by create-ailk from this app's package.json dependencies (#8843).
4
+ // Do not edit: every scaffold, `create-ailk add-module` and
5
+ // `create-ailk update` rewrites it. It imports nothing, so edge code may
6
+ // read it as well as the locale layout.
7
+
8
+ /** The scheduling module's booking pages (/meet, /private) exist. */
9
+ export const SCHEDULING_PAGES: boolean = false;
@@ -0,0 +1,202 @@
1
+ "use server";
2
+
3
+ /**
4
+ * subscribeNewsletterAction — Server Action paired with the
5
+ * `ailkSubscribeNewsletter` MCP tool (#6730 D9).
6
+ *
7
+ * `/newsletter` and the footer render the newsletter magnet's
8
+ * `NewsletterFormInline` bound to this action. Validates the address with
9
+ * the tool's own input schema, calls `POST /v1/leads/subscribe` through
10
+ * `@working-theory/api-client`, and maps the outcome the same way
11
+ * `captureLeadAction` does: the API's 503 `lead_capture_not_configured` →
12
+ * `unavailable`; any other non-2xx or thrown error → the generic `error`;
13
+ * only a 2xx is `success`.
14
+ *
15
+ * Visitor forwarding (#7394): forwards the visitor exactly as
16
+ * `captureLeadAction` does, through `visitorForwardHeaders` and the
17
+ * server-only `AILK_VISITOR_FORWARD_KEY`; without them the call is made
18
+ * with one argument, as before.
19
+ *
20
+ * Through the magnet (#8847 D11): /newsletter, /unsubscribe's sibling footer
21
+ * signup and the page itself all render the workspace's `newsletter`
22
+ * magnet, so a signup goes through it. The action re-reads the magnet; a post
23
+ * without `consent=true` while that magnet has a consent field (or, with no
24
+ * magnet, ever) answers a `consent` field error and nothing is sent. When the
25
+ * magnet maps, the
26
+ * address and the consent go to `POST /v1/flows/newsletter/steps` — the step
27
+ * key, the email field's name and the consent field's purposes all come from
28
+ * the server's own read, never the post (SEC-8847-A) — which writes the lead,
29
+ * the consent ledger row and the magnet's welcome email. A 2xx that completes
30
+ * is success; a 503 `lead_capture_not_configured` or an unreachable API
31
+ * (status 0, 502, 504) is `unavailable`. Otherwise (a Free build has no flow
32
+ * route) it posts `leads.subscribe`, as before.
33
+ */
34
+
35
+ import { flows, leads, visitorForwardHeaders } from "@working-theory/api-client";
36
+ import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
37
+ import {
38
+ NEWSLETTER_FLOW_SLUG,
39
+ subscribeNewsletterInputSchema,
40
+ } from "@working-theory/validation";
41
+ import { cookies, headers } from "next/headers";
42
+ import { getLocale } from "next-intl/server";
43
+
44
+ import { checkRateLimit, clientIpFromHeaders } from "../src/lib/rate-limit";
45
+
46
+ import { consentVisitorHeader, readConsentVisitor } from "./consent-visitor";
47
+ import { inPageView, readServedMagnet } from "./in-page-magnet";
48
+
49
+ const GENERIC_ERROR = "Something went wrong, please try again.";
50
+ const CONSENT_REQUIRED = "Please accept the terms to subscribe.";
51
+ const RATE_LIMITED = "Too many signups from here. Please try again in a few minutes.";
52
+ // #8847 (security review) — the contact action's per-visitor budget: the hub
53
+ // sees this site's egress address on the step route, so one visitor must not
54
+ // spend the bucket every visitor shares.
55
+ const RATE_LIMIT_MAX_CALLS = 30;
56
+ const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
57
+
58
+ /** The API's 503 for a deployment with nowhere to keep a lead. */
59
+ function isNotConfigured(res: { status: number; body: unknown }): boolean {
60
+ return (
61
+ res.status === 503 &&
62
+ typeof res.body === "object" &&
63
+ res.body !== null &&
64
+ (res.body as { error?: unknown }).error === "lead_capture_not_configured"
65
+ );
66
+ }
67
+
68
+ /** An API that could not be reached: a thrown call (0), a 502 or a 504. */
69
+ function isApiUnreachable(res: { status: number }): boolean {
70
+ return res.status === 0 || res.status === 502 || res.status === 504;
71
+ }
72
+
73
+ /**
74
+ * The signup through the served newsletter magnet, or `null` when it does not
75
+ * map (absent, draft, a Free build with no flow route, or not a one-field
76
+ * signup) — the caller then posts `leads.subscribe`.
77
+ */
78
+ async function submitThroughMagnet(
79
+ email: string,
80
+ consented: boolean,
81
+ init: { headers: Record<string, string> } | undefined,
82
+ ): Promise<LeadCaptureFormState | null> {
83
+ const magnet = await readServedMagnet(NEWSLETTER_FLOW_SLUG);
84
+ let locale = "en";
85
+ try {
86
+ locale = await getLocale();
87
+ } catch {
88
+ // No request-scoped locale — the default locale.
89
+ }
90
+ const view = magnet ? inPageView(magnet, locale, "newsletter") : null;
91
+ if (view === null || !("kind" in view.form)) return null;
92
+ const { emailField, consent } = view.form;
93
+ // A magnet without a consent field (a wizard-built newsletter) asks for none.
94
+ if (consent !== undefined && !consented) {
95
+ return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
96
+ }
97
+ const values: Record<string, unknown> = { [emailField]: email };
98
+ if (consent !== undefined) {
99
+ for (const purpose of consent.purposes) values[`${consent.field}.${purpose}`] = true;
100
+ }
101
+ const payload = { stepKey: view.stepKey, values, locale };
102
+ try {
103
+ const res = init
104
+ ? await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload, init)
105
+ : await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload);
106
+ if (res.ok) return res.body.completed ? { kind: "success" } : { kind: "error", message: GENERIC_ERROR };
107
+ if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
108
+ return { kind: "error", message: GENERIC_ERROR };
109
+ } catch (err) {
110
+ // The api-client never attaches the request payload to a thrown error.
111
+ console.error("[subscribeNewsletterAction]", err);
112
+ return { kind: "unavailable" };
113
+ }
114
+ }
115
+
116
+ export async function subscribeNewsletterAction(
117
+ _prevState: LeadCaptureFormState,
118
+ formData: FormData,
119
+ ): Promise<LeadCaptureFormState> {
120
+ // Honeypot: a bot that fills the hidden field gets a person's answer, and
121
+ // nothing is sent.
122
+ const honeypot = formData.get(LEAD_CAPTURE_HONEYPOT);
123
+ if (typeof honeypot === "string" && honeypot !== "") return { kind: "success" };
124
+
125
+ const parsed = subscribeNewsletterInputSchema.safeParse({
126
+ email: formData.get("email"),
127
+ });
128
+ if (!parsed.success) {
129
+ return {
130
+ kind: "error",
131
+ fieldErrors: { email: parsed.error.issues[0]?.message ?? "Invalid email" },
132
+ };
133
+ }
134
+
135
+ let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
136
+ try {
137
+ requestHeaders = await headers();
138
+ } catch {
139
+ // No request scope to read — no forwarding, and the visitor is "unknown".
140
+ }
141
+
142
+ // No readable address: skip the per-visitor limit rather than put every
143
+ // visitor in one shared bucket (the contact action's rule).
144
+ const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
145
+ if (
146
+ visitor !== "unknown" &&
147
+ !checkRateLimit(`newsletter:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
148
+ ) {
149
+ return { kind: "error", message: RATE_LIMITED };
150
+ }
151
+
152
+ let init: { headers: Record<string, string> } | undefined;
153
+ if (requestHeaders) {
154
+ const forwarded = visitorForwardHeaders(requestHeaders, process.env.AILK_VISITOR_FORWARD_KEY);
155
+ if (forwarded) init = { headers: forwarded };
156
+ }
157
+
158
+ // #8438 D12 — the banner's visitor id, so a later subscribe links its
159
+ // cookie choices to the client record. Absent or invalid → no header.
160
+ try {
161
+ const visitorId = readConsentVisitor(await cookies());
162
+ if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
163
+ } catch {
164
+ // No request scope to read — subscribe with no visitor-link header.
165
+ }
166
+
167
+ const consented = formData.get("consent") === "true";
168
+ // The step route keys its limit on the forwarded address, as the walk's
169
+ // action sends it (flow.actions.ts).
170
+ const stepInit =
171
+ visitor !== "unknown"
172
+ ? { headers: { "x-forwarded-for": visitor, ...(init?.headers ?? {}) } }
173
+ : init;
174
+ const viaMagnet = await submitThroughMagnet(parsed.data.email, consented, stepInit);
175
+ if (viaMagnet !== null) return viaMagnet;
176
+ // The Free path renders the seeded config, whose consent box gates the post.
177
+ if (!consented) return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
178
+
179
+ const siteId = process.env.AILK_SITE_ID ?? "default";
180
+ const body = { siteId, source: "newsletter", email: parsed.data.email };
181
+ try {
182
+ const res = init
183
+ ? await leads.subscribe(body, init)
184
+ : await leads.subscribe(body);
185
+ if (!res.ok) {
186
+ const error =
187
+ typeof res.body === "object" && res.body !== null
188
+ ? (res.body as { error?: unknown }).error
189
+ : undefined;
190
+ if (res.status === 503 && error === "lead_capture_not_configured") {
191
+ return { kind: "unavailable" };
192
+ }
193
+ return { kind: "error", message: GENERIC_ERROR };
194
+ }
195
+ return { kind: "success" };
196
+ } catch (err) {
197
+ // The api-client never attaches the request payload to a thrown error,
198
+ // so logging `err` carries no subscriber address.
199
+ console.error("[subscribeNewsletterAction]", err);
200
+ return { kind: "error", message: GENERIC_ERROR };
201
+ }
202
+ }