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
@@ -73,12 +73,19 @@
73
73
  },
74
74
  "about": {
75
75
  "heading": "About",
76
- "emptyState": "About page content coming soon."
76
+ "emptyState": "About page content coming soon.",
77
+ "viewProfile": "View profile"
77
78
  },
78
79
  "blog": {
79
80
  "heading": "Blog",
80
81
  "emptyState": "No posts yet. Drop MDX files into",
81
- "emptyStateTrailing": "to populate this list."
82
+ "emptyStateTrailing": "to populate this list.",
83
+ "allTopics": "All",
84
+ "topicsNav": "Blog topics",
85
+ "latestPosts": "Latest posts",
86
+ "previousPosts": "Previous posts",
87
+ "readMore": "Read more",
88
+ "sidebar": "More from this site"
82
89
  },
83
90
  "docs": {
84
91
  "heading": "Docs",
@@ -87,7 +94,8 @@
87
94
  },
88
95
  "careers": {
89
96
  "heading": "Careers",
90
- "emptyState": "Career listings coming soon."
97
+ "emptyState": "Career listings coming soon.",
98
+ "applicationSuccess": "Thank you for applying! We have your application and will be in touch."
91
99
  },
92
100
  "case-studies": {
93
101
  "heading": "Case Studies",
@@ -118,7 +126,9 @@
118
126
  "sending": "Sending…",
119
127
  "sendMessage": "Send message",
120
128
  "successMessage": "Thank you for reaching out! We will be in touch shortly.",
121
- "consentStatement": "I accept the [Terms](terms).",
129
+ "trackInAccount": "Track this in your account",
130
+ "consentStatement": "I accept the [Terms](terms), [Privacy Policy](privacy), and [Working Agreement](working_agreement).",
131
+ "consentRequired": "Please accept the terms to send your message.",
122
132
  "genericError": "Something went wrong, please try again.",
123
133
  "unavailableHeading": "Get in touch",
124
134
  "unavailableBody": "Email us at {email} and we'll reply.",
@@ -132,8 +142,12 @@
132
142
  "namePrompt": "What's your name?",
133
143
  "messageAck": "Good to meet you.",
134
144
  "messagePrompt": "What are you hoping to get done?",
135
- "emailAck": "Got it — last one.",
145
+ "emailAck": "Got it.",
136
146
  "emailPrompt": "What's the best email to reach you at?",
147
+ "consentAck": "Thanks — one last thing.",
148
+ "consentPrompt": "Do you accept our Terms, Privacy Policy and Working Agreement? They're linked at the foot of every page.",
149
+ "consentAccept": "Yes, I accept",
150
+ "consentDecline": "Not yet",
137
151
  "skipMessage": "No worries — here's our form instead.",
138
152
  "completionMessage": "Thanks! We'll be in touch shortly.",
139
153
  "submitError": "We couldn't send that automatically — please review and send below."
@@ -304,7 +318,8 @@
304
318
  },
305
319
  "checkoutThankYou": {
306
320
  "title": "Thank you",
307
- "body": "Your payment went through. We'll email you a receipt and the next steps."
321
+ "body": "Your payment went through. We'll email you a receipt and the next steps.",
322
+ "openAccount": "Open your account"
308
323
  },
309
324
  "checkoutUnavailable": {
310
325
  "title": "Checkout isn't available right now",
@@ -317,9 +332,8 @@
317
332
  "emptyState": "Resources listing coming soon."
318
333
  },
319
334
  "schedule": {
320
- "title": "Book a call",
335
+ "heading": "Book time with me",
321
336
  "description": "Pick a time that works for you.",
322
- "eyebrow": "Book a call",
323
337
  "headline": "Pick a time that works",
324
338
  "confirmed": {
325
339
  "title": "You're booked",
@@ -352,6 +366,10 @@
352
366
  "heading": "Terms of Service",
353
367
  "emptyState": "Terms of service content coming soon."
354
368
  },
369
+ "workingAgreement": {
370
+ "heading": "Working Agreement",
371
+ "emptyState": "Working agreement content coming soon."
372
+ },
355
373
  "testimonials": {
356
374
  "heading": "Testimonials",
357
375
  "emptyState": "Testimonials coming soon."
@@ -488,8 +506,7 @@
488
506
  "signIn": {
489
507
  "title": "Sign in",
490
508
  "description": "Sign in to your account.",
491
- "heading": "Sign in to your account",
492
- "subhead": "Welcome back. Choose how you'd like to continue.",
509
+ "heading": "Welcome back",
493
510
  "notConfiguredSubhead": "Sign-in is not configured for this site yet. Set a provider's environment variables to enable it.",
494
511
  "ssoPrefix": "Sign in with",
495
512
  "google": "Google",
@@ -509,7 +526,14 @@
509
526
  "emailLinkSentBody": "If an account uses that address, a sign-in link is on its way. It works once and expires in 5 minutes.",
510
527
  "emailLinkFailed": "We couldn't send a sign-in link. Try again.",
511
528
  "emailLinkRateLimited": "Too many requests. Wait a minute, then try again.",
512
- "emailLinkExpired": "That sign-in link has expired or was already used. Request a new one."
529
+ "emailLinkExpired": "That sign-in link has expired or was already used. Request a new one.",
530
+ "useEmailLink": "Email me a sign-in link instead",
531
+ "emailPlaceholder": "you@example.com",
532
+ "backTo": "Back to {site}",
533
+ "acknowledgement": "You acknowledge that you read and agree to our",
534
+ "termsLink": "Terms of Service",
535
+ "privacyLink": "Privacy Policy",
536
+ "workingAgreementLink": "Working Agreement"
513
537
  },
514
538
  "auth": {
515
539
  "signUpTitle": "Create an account",
@@ -535,6 +559,7 @@
535
559
  "signUpFooterPrompt": "Already have an account?",
536
560
  "signUpFooterLink": "Sign in",
537
561
  "signUpAgreementsLegend": "Agreements",
562
+ "signUpAgreementsRequired": "Accept the agreements to create an account.",
538
563
  "signUpNotConfiguredSubhead": "Sign-up is not configured for this site yet. Set a provider's environment variables to enable it.",
539
564
  "signUpFailed": "We couldn't create your account. Check your details and try again.",
540
565
  "providerFailed": "We couldn't start sign-up with that provider. Try again.",
@@ -841,7 +866,9 @@
841
866
  "agreements": {
842
867
  "titleNew": "Please accept our terms",
843
868
  "titleUpdated": "We've updated our terms",
869
+ "lead": "To keep using your account, accept the agreements below.",
844
870
  "accept": "Accept",
871
+ "signOut": "Sign out",
845
872
  "error": "We couldn't save that. Try again."
846
873
  },
847
874
  "gate": {
@@ -851,7 +878,8 @@
851
878
  "homeLink": "Back to the site",
852
879
  "hubHeadline": "Sign in to your account",
853
880
  "hubDescription": "You sign in on our main site, then come straight back here.",
854
- "hubSignIn": "Sign in"
881
+ "hubSignIn": "Sign in",
882
+ "title": "Sign in"
855
883
  },
856
884
  "profile": {
857
885
  "email": "Email",
@@ -30,6 +30,7 @@
30
30
  "@working-theory/schema-pack-registry": "workspace:*",
31
31
  "@working-theory/schema-validator": "workspace:*",
32
32
  "@working-theory/site-config": "workspace:*",
33
+ "@working-theory/spoke-checkout": "workspace:*",
33
34
  "@working-theory/templates": "workspace:*",
34
35
  "@working-theory/theme": "workspace:*",
35
36
  "@working-theory/ui": "workspace:*",
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The site's one lead writer (#8747): every lead carries `payload.site` (from
3
+ * `AILK_LEAD_SITE`) and `payload.page`, spread last so no caller field can
4
+ * override them; no `siteId` is sent (the hub names the tenant itself); only
5
+ * the visitor and consent-visitor headers are forwarded; a thrown submit
6
+ * resolves as `{ ok: false, status: 0 }` and logs no field value.
7
+ */
8
+ jest.mock("@working-theory/api-client", () => ({
9
+ leads: { submit: jest.fn() },
10
+ }));
11
+
12
+ import { leads } from "@working-theory/api-client";
13
+
14
+ import { writeLead } from "../lead-write";
15
+
16
+ const mockSubmit = leads.submit as jest.Mock;
17
+ const priorSite = process.env.AILK_LEAD_SITE;
18
+
19
+ afterEach(() => {
20
+ jest.clearAllMocks();
21
+ if (priorSite === undefined) delete process.env.AILK_LEAD_SITE;
22
+ else process.env.AILK_LEAD_SITE = priorSite;
23
+ });
24
+
25
+ type Body = { siteId?: unknown; source: string; email: string; payload: Record<string, unknown> };
26
+
27
+ function sentBody(): Body {
28
+ return mockSubmit.mock.calls[0][0] as Body;
29
+ }
30
+
31
+ describe("writeLead — the site marker", () => {
32
+ it("marks payload.site from AILK_LEAD_SITE and payload.page from the caller, and sends no siteId", async () => {
33
+ process.env.AILK_LEAD_SITE = "wendihenry";
34
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
35
+
36
+ const res = await writeLead({
37
+ source: "contact-form",
38
+ email: "jane@example.com",
39
+ page: "contact",
40
+ payload: { name: "Jane" },
41
+ });
42
+
43
+ expect(res.ok).toBe(true);
44
+ expect(sentBody()).toEqual({
45
+ source: "contact-form",
46
+ email: "jane@example.com",
47
+ payload: { name: "Jane", site: "wendihenry", page: "contact" },
48
+ });
49
+ expect(sentBody()).not.toHaveProperty("siteId");
50
+ });
51
+
52
+ it("a caller's own site or page field never overrides the marker", async () => {
53
+ process.env.AILK_LEAD_SITE = "wendihenry";
54
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
55
+
56
+ await writeLead({
57
+ source: "contact-form",
58
+ email: "jane@example.com",
59
+ page: "contact",
60
+ payload: { site: "attacker", page: "checkout" },
61
+ });
62
+
63
+ expect(sentBody().payload).toEqual({ site: "wendihenry", page: "contact" });
64
+ });
65
+
66
+ it("trims AILK_LEAD_SITE", async () => {
67
+ process.env.AILK_LEAD_SITE = " wendihenry\n";
68
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
69
+
70
+ await writeLead({ source: "contact-form", email: "jane@example.com", page: "contact" });
71
+
72
+ expect(sentBody().payload.site).toBe("wendihenry");
73
+ });
74
+
75
+ it.each([
76
+ ["unset", undefined],
77
+ ["empty", ""],
78
+ ["blank", " "],
79
+ ])("with AILK_LEAD_SITE %s, writes no site — and drops a caller's own site", async (_label, value) => {
80
+ if (value === undefined) delete process.env.AILK_LEAD_SITE;
81
+ else process.env.AILK_LEAD_SITE = value;
82
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
83
+
84
+ await writeLead({
85
+ source: "contact-form",
86
+ email: "jane@example.com",
87
+ page: "contact",
88
+ payload: { name: "Jane", site: "attacker" },
89
+ });
90
+
91
+ expect(sentBody().payload).toEqual({ name: "Jane", page: "contact" });
92
+ });
93
+ });
94
+
95
+ describe("writeLead — forwarded headers", () => {
96
+ it("forwards only the visitor and consent-visitor headers", async () => {
97
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
98
+
99
+ await writeLead(
100
+ { source: "contact-form", email: "jane@example.com", page: "contact" },
101
+ {
102
+ headers: {
103
+ "x-ailk-visitor-ip": "203.0.113.5",
104
+ "x-ailk-visitor-forward-key": "k",
105
+ "x-ailk-consent-visitor": "6f1e2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
106
+ "x-ailk-spoke-key": "forged",
107
+ "content-type": "text/plain",
108
+ },
109
+ },
110
+ );
111
+
112
+ expect(mockSubmit.mock.calls[0][1]).toEqual({
113
+ headers: {
114
+ "x-ailk-visitor-ip": "203.0.113.5",
115
+ "x-ailk-visitor-forward-key": "k",
116
+ "x-ailk-consent-visitor": "6f1e2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
117
+ },
118
+ });
119
+ });
120
+
121
+ it("calls leads.submit with one argument when no header survives", async () => {
122
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
123
+
124
+ await writeLead(
125
+ { source: "contact-form", email: "jane@example.com", page: "contact" },
126
+ { headers: { "x-ailk-spoke-key": "forged" } },
127
+ );
128
+
129
+ expect(mockSubmit.mock.calls[0]).toHaveLength(1);
130
+ });
131
+ });
132
+
133
+ describe("writeLead — a thrown submit", () => {
134
+ it("resolves as status 0 and logs the error name only", async () => {
135
+ mockSubmit.mockRejectedValueOnce(new TypeError("fetch failed for jane@example.com"));
136
+ const spy = jest.spyOn(console, "error").mockImplementation(() => undefined);
137
+
138
+ const res = await writeLead({
139
+ source: "contact-form",
140
+ email: "jane@example.com",
141
+ page: "contact",
142
+ payload: { name: "Jane" },
143
+ });
144
+
145
+ expect(res).toEqual({ ok: false, status: 0, body: null });
146
+ const logged = spy.mock.calls.flat().map(String).join(" ");
147
+ expect(logged).toContain("TypeError");
148
+ expect(logged).not.toContain("jane@example.com");
149
+ expect(logged).not.toContain("Jane");
150
+ spy.mockRestore();
151
+ });
152
+
153
+ it("passes the api-client's own failure through unchanged", async () => {
154
+ const failure = { ok: false, status: 503, body: { error: "lead_capture_not_configured" } };
155
+ mockSubmit.mockResolvedValueOnce(failure);
156
+
157
+ expect(
158
+ await writeLead({ source: "contact-form", email: "jane@example.com", page: "contact" }),
159
+ ).toEqual(failure);
160
+ });
161
+ });
162
+
163
+ describe("writeLead — terms acceptance (#8730)", () => {
164
+ it("sends consent: true at the top level when the caller says the terms were accepted", async () => {
165
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
166
+
167
+ await writeLead({
168
+ source: "contact-form",
169
+ email: "jane@example.com",
170
+ page: "contact",
171
+ consent: true,
172
+ payload: { name: "Jane" },
173
+ });
174
+
175
+ const body = mockSubmit.mock.calls[0][0] as Record<string, unknown>;
176
+ expect(body["consent"]).toBe(true);
177
+ expect((body["payload"] as Record<string, unknown>)["consent"]).toBeUndefined();
178
+ });
179
+
180
+ it("sends no consent key at all when the caller does not set it", async () => {
181
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
182
+
183
+ await writeLead({ source: "contact-form", email: "jane@example.com", page: "contact" });
184
+
185
+ expect(mockSubmit.mock.calls[0][0]).not.toHaveProperty("consent");
186
+ });
187
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The per-visitor limiter the /contact action runs before it writes a lead
3
+ * (#8747): a fixed window per key, refusing the call past the limit, and a
4
+ * client address read from the platform's own headers.
5
+ */
6
+ import {
7
+ __resetRateLimit,
8
+ checkRateLimit,
9
+ clientIpFromHeaders,
10
+ } from "../rate-limit";
11
+
12
+ const WINDOW_MS = 10 * 60 * 1000;
13
+
14
+ function headersOf(values: Record<string, string>): { get(name: string): string | null } {
15
+ return { get: (name) => values[name] ?? null };
16
+ }
17
+
18
+ afterEach(() => {
19
+ __resetRateLimit();
20
+ jest.useRealTimers();
21
+ });
22
+
23
+ describe("checkRateLimit", () => {
24
+ it("admits exactly `limit` calls in a window and refuses the next", () => {
25
+ for (let i = 0; i < 30; i += 1) {
26
+ expect(checkRateLimit("contact:203.0.113.5", 30, WINDOW_MS)).toBe(true);
27
+ }
28
+ expect(checkRateLimit("contact:203.0.113.5", 30, WINDOW_MS)).toBe(false);
29
+ });
30
+
31
+ it("counts each key on its own", () => {
32
+ for (let i = 0; i < 30; i += 1) checkRateLimit("contact:a", 30, WINDOW_MS);
33
+ expect(checkRateLimit("contact:a", 30, WINDOW_MS)).toBe(false);
34
+ expect(checkRateLimit("contact:b", 30, WINDOW_MS)).toBe(true);
35
+ });
36
+
37
+ it("opens a new window once the old one ends", () => {
38
+ jest.useFakeTimers();
39
+ jest.setSystemTime(new Date("2026-10-15T10:00:00Z"));
40
+ for (let i = 0; i < 30; i += 1) checkRateLimit("contact:a", 30, WINDOW_MS);
41
+ expect(checkRateLimit("contact:a", 30, WINDOW_MS)).toBe(false);
42
+
43
+ jest.setSystemTime(new Date("2026-10-15T10:10:00Z"));
44
+ expect(checkRateLimit("contact:a", 30, WINDOW_MS)).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe("clientIpFromHeaders", () => {
49
+ it("prefers x-vercel-forwarded-for, first value", () => {
50
+ expect(
51
+ clientIpFromHeaders(
52
+ headersOf({
53
+ "x-vercel-forwarded-for": "198.51.100.7, 10.0.0.1",
54
+ "x-forwarded-for": "203.0.113.5",
55
+ }),
56
+ ),
57
+ ).toBe("198.51.100.7");
58
+ });
59
+
60
+ it("falls back to x-forwarded-for, then x-real-ip, then unknown", () => {
61
+ expect(clientIpFromHeaders(headersOf({ "x-forwarded-for": "203.0.113.5, 10.0.0.1" }))).toBe(
62
+ "203.0.113.5",
63
+ );
64
+ expect(clientIpFromHeaders(headersOf({ "x-real-ip": "192.0.2.9" }))).toBe("192.0.2.9");
65
+ expect(clientIpFromHeaders(headersOf({}))).toBe("unknown");
66
+ });
67
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * lead-write.ts — this site's lead writer (#8747): the /contact action writes
3
+ * through it, and so can any other page that writes a lead.
4
+ *
5
+ * A lead goes to `POST /v1/leads/submit` through `@working-theory/api-client`.
6
+ * The API resolves the lead's workspace itself — from the spoke key on a hub,
7
+ * from its own site alias otherwise — and ignores a body `siteId`, so none is
8
+ * sent. The site marks itself in the payload instead: `payload.site` is
9
+ * `AILK_LEAD_SITE` (the same variable `apps/mcp`'s `capture_lead` reads,
10
+ * `LEAD_SITE_MARKER_KEY`) and `payload.page` the page key, both spread LAST so
11
+ * no caller field can override them. With `AILK_LEAD_SITE` unset or blank the
12
+ * lead carries no `site` at all — a caller's own `site` is dropped, never sent.
13
+ *
14
+ * The marker is attribution, not authorization: the route is public and a
15
+ * direct caller can write any `payload.site`. Nothing may gate on it.
16
+ *
17
+ * Terms acceptance (#8730): a caller that collected the visitor's acceptance
18
+ * passes `consent: true`, sent as the body's top-level `consent`. The API
19
+ * refuses a contact lead without it and records `payload.consent` itself, so
20
+ * nothing about the acceptance is written into the payload here.
21
+ *
22
+ * Forwarded headers: only the two `visitorForwardHeaders` builds (#7394) and
23
+ * the consent-visitor id (#8438 D12) reach the api-client, so no caller can
24
+ * set its own `x-ailk-spoke-key` or `content-type`. With none of the three,
25
+ * `leads.submit` is called with one argument.
26
+ *
27
+ * Never throws. The api-client's own result passes through unchanged, so a
28
+ * caller still reads the API's 503 `lead_capture_not_configured`. A thrown
29
+ * submit — the API could not be reached — resolves as
30
+ * `{ ok: false, status: 0, body: null }` and logs the error's NAME only: its
31
+ * message could echo the payload.
32
+ */
33
+ import { leads } from "@working-theory/api-client";
34
+ import { LEAD_SITE_MARKER_KEY } from "@working-theory/validation";
35
+
36
+ export interface LeadWriteInput {
37
+ source: string;
38
+ email: string;
39
+ /** The page key the marker carries, e.g. `contact` or `checkout`. */
40
+ page: string;
41
+ /** The visitor accepted the terms (#8730). Sent only when `true`. */
42
+ consent?: boolean;
43
+ payload?: Record<string, unknown>;
44
+ }
45
+
46
+ type SubmitInit = Parameters<typeof leads.submit>[1];
47
+ type SubmitResult = Awaited<ReturnType<typeof leads.submit>>;
48
+
49
+ /** The only request headers a lead write forwards. */
50
+ const FORWARDED_HEADERS = [
51
+ "x-ailk-visitor-ip",
52
+ "x-ailk-visitor-forward-key",
53
+ "x-ailk-consent-visitor",
54
+ ] as const;
55
+
56
+ function forwardOnly(init: SubmitInit): SubmitInit {
57
+ const headers: Record<string, string> = {};
58
+ for (const name of FORWARDED_HEADERS) {
59
+ const value = init?.headers?.[name];
60
+ if (typeof value === "string") headers[name] = value;
61
+ }
62
+ return Object.keys(headers).length > 0 ? { headers } : undefined;
63
+ }
64
+
65
+ /** The site marker from configuration, or `undefined` when unset or blank. */
66
+ function leadSite(): string | undefined {
67
+ return process.env.AILK_LEAD_SITE?.trim() || undefined;
68
+ }
69
+
70
+ export async function writeLead(input: LeadWriteInput, init?: SubmitInit): Promise<SubmitResult> {
71
+ const site = leadSite();
72
+ const { [LEAD_SITE_MARKER_KEY]: _callerSite, ...callerPayload } = input.payload ?? {};
73
+ const body = {
74
+ source: input.source,
75
+ email: input.email,
76
+ ...(input.consent === true && { consent: true }),
77
+ payload: {
78
+ ...callerPayload,
79
+ ...(site && { [LEAD_SITE_MARKER_KEY]: site }),
80
+ page: input.page,
81
+ },
82
+ };
83
+ const forward = forwardOnly(init);
84
+ try {
85
+ return forward ? await leads.submit(body, forward) : await leads.submit(body);
86
+ } catch (err) {
87
+ console.error(`[lead-write] submit failed: ${err instanceof Error ? err.name : "unknown"}`);
88
+ return { ok: false, status: 0, body: null };
89
+ }
90
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * rate-limit.ts — a best-effort, in-memory, per-instance limiter (#8747).
3
+ *
4
+ * The /contact Server Action runs it before it writes a lead. From a server
5
+ * action the hub sees this site's own egress address, so without a limit
6
+ * here one visitor could drain the hub's per-IP budget for every visitor of
7
+ * the site. Brought upstream from the consumer sites' copies
8
+ * (salessmyth/samhenry, salessmyth/wendihenry `apps/web/src/lib/rate-limit.ts`).
9
+ *
10
+ * NOT a distributed limiter: state lives in one serverless instance's memory
11
+ * and resets on a cold start. It raises the bar against a flood on one warm
12
+ * instance; it does not stop one spread across many. The hub keeps its own
13
+ * per-IP limit on the route.
14
+ */
15
+
16
+ type Bucket = { count: number; resetAt: number };
17
+
18
+ const buckets = new Map<string, Bucket>();
19
+
20
+ // Bound the map so a caller rotating keys (spoofed addresses) cannot grow it
21
+ // without limit between resets.
22
+ const MAX_TRACKED_KEYS = 5000;
23
+
24
+ /** Test-only reset. Not reachable from a request. */
25
+ export function __resetRateLimit(): void {
26
+ buckets.clear();
27
+ }
28
+
29
+ /**
30
+ * Fixed-window check. `true` when the caller is under `limit` in the current
31
+ * window (and counts the call); `false` once it has used `limit` calls.
32
+ */
33
+ export function checkRateLimit(key: string, limit: number, windowMs: number): boolean {
34
+ const now = Date.now();
35
+ const existing = buckets.get(key);
36
+
37
+ if (!existing || existing.resetAt <= now) {
38
+ if (buckets.size >= MAX_TRACKED_KEYS) pruneExpired(now);
39
+ buckets.set(key, { count: 1, resetAt: now + windowMs });
40
+ return true;
41
+ }
42
+
43
+ if (existing.count >= limit) return false;
44
+
45
+ existing.count += 1;
46
+ return true;
47
+ }
48
+
49
+ function pruneExpired(now: number): void {
50
+ for (const [key, bucket] of buckets) {
51
+ if (bucket.resetAt <= now) buckets.delete(key);
52
+ }
53
+ // Nothing expired (keys rotated inside one window): evict the oldest entry
54
+ // — `Map` iterates in insertion order — so the bound always holds.
55
+ if (buckets.size >= MAX_TRACKED_KEYS) {
56
+ const oldestKey = buckets.keys().next().value;
57
+ if (oldestKey !== undefined) buckets.delete(oldestKey);
58
+ }
59
+ }
60
+
61
+ /** The subset of Next's `headers()` the address read needs. */
62
+ export interface HeaderReader {
63
+ get(name: string): string | null;
64
+ }
65
+
66
+ /**
67
+ * The visitor's address, coarse: `x-vercel-forwarded-for`, else
68
+ * `x-forwarded-for`, else `x-real-ip` (first value of each), else
69
+ * `"unknown"`. Vercel's edge overwrites these with the connecting address;
70
+ * behind an edge that appends instead, a caller chooses its own key. An
71
+ * abuse-throttling key only — never an authorization decision.
72
+ */
73
+ export function clientIpFromHeaders(headers: HeaderReader): string {
74
+ for (const name of ["x-vercel-forwarded-for", "x-forwarded-for"]) {
75
+ const value = headers.get(name);
76
+ if (value) return value.split(",")[0]?.trim() || "unknown";
77
+ }
78
+ return headers.get("x-real-ip") || "unknown";
79
+ }
@@ -1,5 +1,52 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.43.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#8775](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8775) [`ee8e713`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/ee8e713b8194e8d65052f4b1dca928ca0c970318) Thanks [@claudio-planck](https://github.com/claudio-planck)! - A per-magnet completion setting — lead only, invite to account, or continue to checkout ([#8757](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8757), Phase 1).
8
+
9
+ - **`@working-theory/database`** — `flow_definitions` gains `completion` (`lead` | `invite` | `checkout`, default `lead`) and `checkout_offering`, closed by three CHECKs. The migration backfills `onboarding`, `request-quote` and `job-application` to `invite` by slug; `contact` and every other magnet stay `lead`. Deploy the migration before the API.
10
+ - **`@working-theory/validation`** — `flow-completion.ts`: `FLOW_COMPLETIONS`, `flowCompletionSettingSchema`, `flowCheckoutOfferingSchema`, `STANDARD_MAGNET_COMPLETIONS`, `leadSubmitNextSchema`, `accountEmailHintSchema`, `accountInvitePath`, the invite caps and the email defaults.
11
+ - **`@working-theory/api-client`** — `Magnet` carries `completion` and `checkoutOffering`, `MagnetUpdateInput` can set them, and `LeadWriteResponse` gains an optional `next`.
12
+ - **`@working-theory/ui`** — `AuthCard` takes an optional `defaultEmail`; `LeadCaptureFormState`'s success state takes an optional `next` link.
13
+ - **`@working-theory/analytics`** — `email` and `login_hint` are scrubbed from URLs like `session_id`.
14
+ - **`create-ailk`** — the API: the magnet PATCH writes the setting alone (`checkout` needs a person, the `commerce` claim and a published offering) and every full row serves it; a `flow:<slug>` submit to an `invite` magnet answers `next` and sends one capped confirmation email whose link starts sign-in with the address prefilled. The web: /contact's thank-you shows "Track this in your account", and sign-in opens with a hinted email on a site and through the hub. The Admin: one "When someone submits" control on the magnet page.
15
+
16
+ - [#8807](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8807) [`956bfe6`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/956bfe60fff4bd9a43135e6a2fb79e6f012e14f8) Thanks [@claudio-planck](https://github.com/claudio-planck)! - Every hosted Stripe Checkout session collects terms acceptance, and a completed checkout records a dated grant for the buyer ([#8765](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8765), PR B).
17
+
18
+ - **`@working-theory/stripe-client`** — both session creators set `consent_collection.terms_of_service: "required"` and a `custom_text.terms_of_service_acceptance` message: the site's account-terms sentence with its Terms, Privacy Policy and Working Agreement links resolved on the session's own return origin. Every session is stamped `metadata.ailk_terms` (`account_terms@2` by default). An optional `termsOfService` input overrides the sentence and its links. A Stripe refusal for a missing Terms URL is rethrown as `TermsOfServiceUrlMissingError`. **Precondition:** the seller's Stripe account needs a Terms of Service URL in its Dashboard's Public details, or Stripe refuses every session with the box.
19
+ - **`@working-theory/validation`** — `CONSENT_SOURCES` gains `checkout`.
20
+ - **`@working-theory/database`** — a migration widens the `consent_grants_source_known` CHECK to the eight sources. It writes no row.
21
+ - **`create-ailk`** — the API: the hub's `checkout.session.completed` webhook records one `checkout` grant per session for the buyer's Person when the session carries the terms stamp and Stripe recorded the consent as accepted. A checkout row is an unverified (any-source) row. The Admin labels the source "Checkout".
22
+
23
+ ### Patch Changes
24
+
25
+ - [#8767](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8767) [`5ff341d`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/5ff341d683d35a024fc1073c1b010aa56956a1d2) Thanks [@claudio-planck](https://github.com/claudio-planck)! - A JobPosting names where to apply, and a standard Job application magnet is seeded like the Contact form and drawn on every job page ([#8746](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8746)).
26
+
27
+ - **`@working-theory/schema`** — `ContentPageInput` gains an optional `origin` (the site's origin, passed by the caller; the package never reads the environment for it). When it is present, the JobPosting builder puts a `potentialAction` `ApplyAction` on the JobPosting whose `target` is the job page's own URL plus `#apply`: `<origin>/<locale>/careers/<slug>#apply`, the same form the page's BreadcrumbList uses. `buildJobPostingNode` takes an optional `applyUrl`. Every other builder ignores `origin`, so CareersList keeps its ItemList unchanged.
28
+ - **`@working-theory/page-renderer`** — `toContentPageInput` passes the render's origin (the one the BreadcrumbList is built from).
29
+ - **`@working-theory/validation`** — the magnet's one source: `JOB_APPLICATION_FLOW_SLUG` (`job-application`), `JOB_APPLICATION_FLOW_TITLE`, `JOB_APPLICATION_LANDING_PAGE` (`/careers`) and `jobApplicationFlowConfig` — one field step of First name, Last name and Email (required), Phone (`tel`) and LinkedIn URL (`url`). `RESERVED_FLOW_SLUGS` gains `job-application`, so no flow-engine write, spoke seed or new listing takes it.
30
+ - **`@working-theory/database`** — the database seed writes the default workspace's Job application magnet: `diagnostic`, `live`, landing page `/careers`, insert-only.
31
+ - **`create-ailk`** — the API seeds the magnet into every workspace insert-only (after `POST /v1/workspaces` and from the seed-presets CLI for existing workspaces), a magnet can be neither renamed from nor to `job-application`, and `GET /v1/content/:type/:slug` passes `_site.mdx`'s `baseUrl` as the origin, so the API's JobPosting JSON-LD carries the ApplyAction too. Each job page `/careers/<slug>` renders the magnet's field step under an `#apply` anchor, falling back to the same built-in form when the magnet is absent or off; a submission is a `flow:job-application` lead whose `name` is first + last and whose hidden `job` is the posting's slug, and the form thanks the applicant with the `careers` catalog's own `applicationSuccess` line. The `/careers` index carries no form.
32
+
33
+ - [#8788](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8788) [`3a89742`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/3a89742f7b816deca6cd604863f2fd041a79abfa) Thanks [@claudio-planck](https://github.com/claudio-planck)! - The standard magnets are seeded with their completion setting ([#8759](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8759)), so a workspace created after the [#8757](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8757) migration no longer depends on its backfill.
34
+
35
+ - **`@working-theory/database`** — the database seed writes `completion` on the default workspace's magnets: `invite` on `request-quote` and `job-application`, `lead` on `contact`. On a fresh install the migration's backfill runs before the seed and finds no rows.
36
+ - **`create-ailk`** — the API's insert-only seed writes `lead` on the Contact form and `invite` on the onboarding and Job application magnets, and `POST /v1/workspaces` creates the `request-quote` magnet at `invite`.
37
+
38
+ - Updated dependencies [[`5032b3e`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/5032b3eefb05bbe67ebddf1b4b07904717100472), [`5ff341d`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/5ff341d683d35a024fc1073c1b010aa56956a1d2), [`ee8e713`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/ee8e713b8194e8d65052f4b1dca928ca0c970318), [`c74bd72`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/c74bd7270fa2cb82bebedfb1b53f89ca6e09917f), [`956bfe6`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/956bfe60fff4bd9a43135e6a2fb79e6f012e14f8), [`97237f2`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/97237f278006a43e17d58709ed9ac3b4f62ae76f), [`1eb9688`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/1eb9688708dbc65cd4e2fdb6efeb40880e1606f0), [`b001413`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/b001413238973f4ecb3d4a5a18968cd7732210d8)]:
39
+ - @working-theory/validation@0.43.0
40
+
41
+ ## 0.42.0
42
+
43
+ ### Patch Changes
44
+
45
+ - [#8740](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/8740) [`d1461bf`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d1461bff10a69aa4cf05627e393bd1902c358b00) Thanks [@samuelhenry](https://github.com/samuelhenry)! - The database seed writes the default workspace's Contact form magnet ([#8661](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/8661)): one live contact-kind magnet at slug `contact`, insert-only, so `/contact` reads it on a freshly seeded site.
46
+
47
+ - Updated dependencies [[`cb2f3a5`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/cb2f3a50c38fe77b92aa52d1462d3d11a5981981), [`425e253`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/425e2538dfd22dfb6f0b6439d473fa2aafcbb883), [`da9253b`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/da9253bb6f8c792e7e75cfe1c1a7bba5ca569e26), [`df1b8e4`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/df1b8e485f9cdd1885925430b7aaa186ffdf6ac1), [`d4e0598`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d4e0598c7659d9349bd56ba184e4ab941e3fde62)]:
48
+ - @working-theory/validation@0.42.0
49
+
3
50
  ## 0.41.0
4
51
 
5
52
  ### Minor Changes