create-ailk 0.20.0 → 0.22.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 (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -57,6 +57,47 @@
57
57
  * file leaves nothing to find, and its retry writes again. `subscribe` and
58
58
  * `callback` never read the header. The key is never logged or echoed.
59
59
  *
60
+ * CONTACT TERMS ACCEPTANCE (#8730). A `submit` whose `source` is a contact
61
+ * source (`isContactLeadSource` in @working-theory/validation:
62
+ * `contact-form`, `conversational-intake`, `flow:contact`) must carry a
63
+ * top-level `consent: true` — the boolean, nothing else — or it is
64
+ * `400 consent_required` before the tenant read or any write. An accepted one
65
+ * gets `payload.consent = { statement, acceptedAt }` written HERE, replacing
66
+ * any `payload.consent` the client sent: `statement` is the platform's
67
+ * contact terms statement (`CONTACT_FORM_TERMS_STATEMENT`) and `acceptedAt`
68
+ * this request's receive time. The `consent` flag itself is never stored. A
69
+ * lead with any other source, `subscribe` and `callback` are unchanged. A
70
+ * direct caller can still send `consent: true`; the gate makes acceptance an
71
+ * explicit, recorded assertion, not proof that a person read the terms.
72
+ *
73
+ * API↔MCP PARITY for `consent` (#8730): no guard entry, for the reason the
74
+ * Idempotency-Key paragraph below gives — this route parses no validation
75
+ * export, so it binds as `api-args-unbound`. `capture_lead` stamps `mcp`, a
76
+ * source this check never reads, so the tool is unchanged.
77
+ *
78
+ * A MAGNET'S COMPLETION (#8757 D6). After a `flow:<slug>` submit is written
79
+ * to the database, `services/magnet-completion.ts` reads that LIVE magnet's
80
+ * completion setting. On `invite` the 201 gains `next: { completion:
81
+ * "invite" }` and — on a first write, never a replay — the submitter is sent
82
+ * one confirmation email whose link starts sign-in with their address
83
+ * prefilled (capped per recipient and per magnet, counted in the database);
84
+ * on `checkout`, re-checked against the `commerce` claim and the published
85
+ * offerings, it gains `next: { completion: "checkout", checkoutOffering }`.
86
+ * `lead`, a draft or missing magnet, or a caller with nowhere to sign in
87
+ * leaves the 201 as it was. A failed read or send never changes the 201,
88
+ * and the completion read has a time budget (`MAGNET_COMPLETION_BUDGET_MS`).
89
+ * `next` is reachable only through a `flow:` source: `capture_lead` posts
90
+ * `source: "mcp"`, so the tool's arguments get the same answer as before and
91
+ * no parity guard row changes (NFR5).
92
+ *
93
+ * A CHECKOUT'S ACCOUNT LINK (#8757 D14). A checkout fulfilment's submit
94
+ * (`source: "web"`, `payload.page: "checkout"`) carries the caller's account
95
+ * origin into `routeLead` as `accountOrigin`, resolved exactly as the invite
96
+ * link's (`accountTargetFor`): the spoke's enabled sign-in client's callback
97
+ * origin, or `FRONTEND_URL`'s on a keyless build with an account area. With
98
+ * none, or a failed read, it is left out and the buyer's purchase email goes
99
+ * without the link. No other submit makes this read, and the 201 is unchanged.
100
+ *
60
101
  * API↔MCP PARITY for the header (#7869 D9, roadmap §12 Q5): NO guard entry.
61
102
  * The guard binds a route's arguments to the validation export its handler
62
103
  * `.safeParse`s on `request.params|query|body`; the header check below is a
@@ -67,6 +108,11 @@
67
108
  */
68
109
 
69
110
  import { track } from "@working-theory/analytics/server";
111
+ import {
112
+ CONTACT_FORM_TERMS_STATEMENT,
113
+ isContactLeadSource,
114
+ type LeadSubmitNext,
115
+ } from "@working-theory/validation";
70
116
  import type { FastifyPluginAsync } from "fastify";
71
117
 
72
118
  import { acceptsKind, resolveLeadDelivery } from "../../lib/lead-delivery.js";
@@ -81,6 +127,11 @@ import {
81
127
  routeLead,
82
128
  type SourceMeta,
83
129
  } from "../../services/lead-routing.js";
130
+ import {
131
+ accountTargetFor,
132
+ magnetCompletionWithinBudget,
133
+ sendInviteConfirmation,
134
+ } from "../../services/magnet-completion.js";
84
135
  import { resolveCallerSite } from "../../services/public-site-resolver.js";
85
136
  import { sendRequestConfirmLink } from "../../services/request-confirm.js";
86
137
 
@@ -105,6 +156,28 @@ function liftString(
105
156
  return (typeof fromPayload === "string" ? fromPayload : topLevel) as string;
106
157
  }
107
158
 
159
+ /** #8730 — the refusal a contact lead without terms acceptance gets. */
160
+ const CONSENT_REQUIRED_BODY = {
161
+ error: "consent_required",
162
+ message: "Accept the terms to send a contact message.",
163
+ } as const;
164
+
165
+ /**
166
+ * #8730 D3 — the accepted terms, recorded by the server on a contact lead's
167
+ * payload, replacing any `consent` the client wrote there. Only a plain
168
+ * object payload is stamped: anything else is left for `routeLead`'s schema
169
+ * to refuse, never spread into an object that would then pass it.
170
+ */
171
+ function withConsentRecord(payload: unknown, acceptedAt: string): unknown {
172
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
173
+ return payload;
174
+ }
175
+ return {
176
+ ...(payload as Record<string, unknown>),
177
+ consent: { statement: CONTACT_FORM_TERMS_STATEMENT, acceptedAt },
178
+ };
179
+ }
180
+
108
181
  /**
109
182
  * Build a typed RouteLeadInput from the raw request body for a given kind.
110
183
  * Explicit destructure per kind prevents unknown fields (e.g. attacker-supplied
@@ -122,6 +195,7 @@ function buildInput(
122
195
  kind: LeadKind,
123
196
  body: Record<string, unknown>,
124
197
  workspaceId: string,
198
+ receivedAt: string,
125
199
  ): RouteLeadInput {
126
200
  const payload = (body["payload"] ?? {}) as Record<string, unknown>;
127
201
 
@@ -131,7 +205,9 @@ function buildInput(
131
205
  siteId: workspaceId,
132
206
  source: body["source"] as string,
133
207
  email: liftString(body, payload, "email"),
134
- payload,
208
+ payload: isContactLeadSource(body["source"])
209
+ ? (withConsentRecord(payload, receivedAt) as Record<string, unknown>)
210
+ : payload,
135
211
  };
136
212
  }
137
213
  if (kind === "subscribe") {
@@ -154,10 +230,31 @@ function buildInput(
154
230
  };
155
231
  }
156
232
 
233
+ /** A spoke checkout fulfilment's submit (#8757 D14): `source: "web"`, `payload.page: "checkout"`. */
234
+ function isCheckoutSubmit(body: Record<string, unknown>): boolean {
235
+ const payload = body["payload"];
236
+ return (
237
+ body["source"] === "web" &&
238
+ typeof payload === "object" &&
239
+ payload !== null &&
240
+ (payload as Record<string, unknown>)["page"] === "checkout"
241
+ );
242
+ }
243
+
157
244
  function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
158
245
  return async (fastify) => {
159
246
  fastify.post(`/v1/leads/${kind}`, async (request, reply) => {
160
247
  const body = request.body as Record<string, unknown>;
248
+
249
+ // #8730 D1 — a contact lead carries the visitor's terms acceptance, or
250
+ // it is refused before the tenant read and any write.
251
+ if (
252
+ kind === "submit" &&
253
+ isContactLeadSource(body?.["source"]) &&
254
+ body?.["consent"] !== true
255
+ ) {
256
+ return reply.status(400).send(CONSENT_REQUIRED_BODY);
257
+ }
161
258
  // request.ip became attacker-influenceable when trustProxy: true went
162
259
  // instance-wide (server.ts, #4730/#4778) — leads/index.ts was never
163
260
  // itself touched by that PR. Traced every consumer of this value
@@ -264,10 +361,20 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
264
361
  sourceMeta.consentVisitorId = consentVisitorId;
265
362
  }
266
363
 
364
+ // #8757 D14 — where a buyer's account lives, for the purchase email.
365
+ let accountOrigin: string | undefined;
366
+ if (kind === "submit" && isCheckoutSubmit(body)) {
367
+ const target = await accountTargetFor(caller).catch(() => null);
368
+ accountOrigin = target?.origin;
369
+ }
370
+
267
371
  const result = await routeLead(
268
- buildInput(kind, body, workspaceId),
372
+ buildInput(kind, body, workspaceId, sourceMeta.receivedAt),
269
373
  sourceMeta,
270
- idempotencyKey === undefined ? {} : { idempotencyKey },
374
+ {
375
+ ...(idempotencyKey !== undefined && { idempotencyKey }),
376
+ ...(accountOrigin !== undefined && { accountOrigin }),
377
+ },
271
378
  );
272
379
 
273
380
  if (!result.ok) {
@@ -357,10 +464,12 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
357
464
  result.persisted === "db" &&
358
465
  caller.kind !== "spoke"
359
466
  ) {
360
- const callbackInput = buildInput(kind, body, workspaceId) as Extract<
361
- RouteLeadInput,
362
- { kind: "callback" }
363
- >;
467
+ const callbackInput = buildInput(
468
+ kind,
469
+ body,
470
+ workspaceId,
471
+ sourceMeta.receivedAt,
472
+ ) as Extract<RouteLeadInput, { kind: "callback" }>;
364
473
  sendRequestConfirmLink(result.id, {
365
474
  name: callbackInput.name,
366
475
  phone: callbackInput.phone,
@@ -370,6 +479,50 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
370
479
  });
371
480
  }
372
481
 
482
+ // #8757 D6 — a written `flow:<slug>` submit answers its magnet's
483
+ // completion. A read that fails, or outlasts its budget, leaves the 201
484
+ // as it was: the lead is saved.
485
+ let next: LeadSubmitNext | undefined;
486
+ const source = body["source"];
487
+ if (
488
+ kind === "submit" &&
489
+ result.persisted === "db" &&
490
+ typeof source === "string" &&
491
+ source.startsWith("flow:")
492
+ ) {
493
+ const slug = source.slice("flow:".length);
494
+ try {
495
+ const completion = await magnetCompletionWithinBudget(workspaceId, slug, () =>
496
+ accountTargetFor(caller),
497
+ );
498
+ if (completion !== null) {
499
+ next = completion.next;
500
+ if (completion.next.completion === "invite" && !result.replayed) {
501
+ const submitInput = buildInput(
502
+ kind,
503
+ body,
504
+ workspaceId,
505
+ sourceMeta.receivedAt,
506
+ ) as Extract<RouteLeadInput, { kind: "submit" }>;
507
+ const locale = submitInput.payload["locale"];
508
+ void sendInviteConfirmation({
509
+ workspaceId,
510
+ slug,
511
+ email: submitInput.email,
512
+ locale: typeof locale === "string" ? locale : undefined,
513
+ completion,
514
+ now: new Date(),
515
+ });
516
+ }
517
+ }
518
+ } catch (error) {
519
+ request.log.error(
520
+ { event: "magnet-completion", error: error instanceof Error ? error.name : "unknown" },
521
+ "lead magnet completion read failed",
522
+ );
523
+ }
524
+ }
525
+
373
526
  // Fire-and-forget: track() never throws; no need to hold the response.
374
527
  // #7869 D6 — a replay records nothing new: the first write counted it.
375
528
  if (!result.replayed) {
@@ -385,6 +538,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
385
538
  id: result.id,
386
539
  persisted: result.persisted,
387
540
  email: result.email,
541
+ ...(next === undefined ? {} : { next }),
388
542
  });
389
543
  });
390
544
  };
@@ -974,6 +974,32 @@ describe("the draft lifecycle (#5036 D1)", () => {
974
974
  expect(row["resumeTokenHash"]).toBeNull();
975
975
  });
976
976
 
977
+ // #8661 D4 — a listing's slug becomes its diagnostic flow's slug, so the
978
+ // standard magnets' slugs are taken; any other name is unchanged.
979
+ it.each([
980
+ ["Contact", "contact-2"],
981
+ ["Onboarding", "onboarding-2"],
982
+ ["Atlas", "atlas"],
983
+ ])("submit: a draft named %s is stored at %s (#8661 D4, AC-2)", async (name, slug) => {
984
+ const { id, resumeToken } = await startDraft(app);
985
+ await app.inject({
986
+ method: "PATCH",
987
+ url: `/v1/project-listings/drafts/${id}`,
988
+ headers: bearer(resumeToken),
989
+ payload: { ...COMPLETE_APPLICATION, name },
990
+ });
991
+
992
+ const res = await app.inject({
993
+ method: "POST",
994
+ url: `/v1/project-listings/drafts/${id}/submit`,
995
+ headers: bearer(resumeToken),
996
+ });
997
+
998
+ expect(res.statusCode).toBe(200);
999
+ expect(res.json()).toEqual({ ok: true, data: { id, slug } });
1000
+ expect(rows.find((r) => r["id"] === id)!["slug"]).toBe(slug);
1001
+ });
1002
+
977
1003
  it("submit: a spent token cannot patch or re-submit the listing", async () => {
978
1004
  const { id, resumeToken } = await startDraft(app);
979
1005
  await app.inject({