create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The Stripe-backed Offerings catalog source (#8922, spec
3
+ * docs/specs/8922-the-may-sell-product-list-spec.md D2; contract
4
+ * docs/specs/8879-the-offerings-security-contract-key-spec.md C8, T1, T7) —
5
+ * the `OfferingsSourceFor` the routes pass to the read services: one
6
+ * Organization's active Products and Prices, read through its own
7
+ * connection's client (#8907 D12) and paged to the normalizer's caps.
8
+ *
9
+ * `stripeCatalogSourceFor` does no work when called, so it can never throw
10
+ * outside the services' failure memo (#8876 D8). It takes a bare
11
+ * Organization id and checks neither the tenant nor the claim — its callers
12
+ * do — so `architecture.yaml` confines it to the exact files that call it.
13
+ *
14
+ * BOUNDED (SEC-8922-3). Each list is paged by calls, not items: at most
15
+ * `OFFERINGS_CATALOG_PRODUCTS_MAX / 100` product calls and
16
+ * `OFFERINGS_CATALOG_PRICES_MAX / 100` price calls, stopping early on
17
+ * `has_more: false`, an empty page, a page whose last id repeats the cursor
18
+ * it was asked with, or a full count. The whole read is bounded by
19
+ * `OFFERINGS_CATALOG_READ_TIMEOUT_MS`; after it fails, no further call is
20
+ * started.
21
+ *
22
+ * A list cut off at its cap while Stripe still had more is logged as
23
+ * `catalog_truncated` (SEC-8922-7), with the Organization and nothing else.
24
+ *
25
+ * Nothing is filtered or cached here: the client lives only inside
26
+ * one read (C8), and every field is normalized once by `normalizeCatalog`
27
+ * (T1). A disconnected or unusable connection rejects, never reading as an
28
+ * empty catalog; a Stripe error rejects as itself, and the services turn it
29
+ * into `OfferingsSourceError` without its text.
30
+ */
31
+ import { logger } from "@working-theory/observability";
32
+
33
+ import {
34
+ OFFERINGS_CATALOG_PRICES_MAX,
35
+ OFFERINGS_CATALOG_PRODUCTS_MAX,
36
+ type OfferingsCatalog,
37
+ type OfferingsCatalogSource,
38
+ } from "./catalog.js";
39
+ import { stripeClientForOrganization } from "./connection.js";
40
+
41
+ import type { OfferingsSourceFor } from "./index.js";
42
+
43
+ /** The longest one catalog read may take, connection lookup included. */
44
+ export const OFFERINGS_CATALOG_READ_TIMEOUT_MS = 20_000;
45
+
46
+ const PAGE_LIMIT = 100;
47
+
48
+ /** No usable connection, or the read timed out. The message is fixed; there is no cause. */
49
+ export class OfferingsCatalogUnavailableError extends Error {
50
+ constructor() {
51
+ super("The Offerings catalog source is unavailable");
52
+ this.name = "OfferingsCatalogUnavailableError";
53
+ }
54
+ }
55
+
56
+ interface ListParams {
57
+ active: true;
58
+ limit: number;
59
+ starting_after?: string;
60
+ }
61
+
62
+ interface ListPage<T> {
63
+ data: T[];
64
+ has_more: boolean;
65
+ }
66
+
67
+ /**
68
+ * One list paged to `maxItems`, by at most `maxItems / PAGE_LIMIT` calls.
69
+ * `truncated` when the bound stopped it while Stripe still had more.
70
+ */
71
+ async function pageAll<T extends { id?: unknown }>(
72
+ list: (params: ListParams) => PromiseLike<ListPage<T>>,
73
+ maxItems: number,
74
+ aborted: () => boolean,
75
+ ): Promise<{ items: T[]; truncated: boolean }> {
76
+ const items: T[] = [];
77
+ let after: string | undefined;
78
+ for (let call = 0; call < maxItems / PAGE_LIMIT && items.length < maxItems; call++) {
79
+ if (aborted()) throw new OfferingsCatalogUnavailableError();
80
+ const page = await list({
81
+ active: true,
82
+ limit: PAGE_LIMIT,
83
+ ...(after !== undefined && { starting_after: after }),
84
+ });
85
+ items.push(...page.data);
86
+ const last = page.data.at(-1)?.id;
87
+ if (!page.has_more || typeof last !== "string" || last === after) return { items, truncated: false };
88
+ after = last;
89
+ }
90
+ return { items, truncated: true };
91
+ }
92
+
93
+ /** The Organization's catalog source. Builds nothing until it is read. */
94
+ export const stripeCatalogSourceFor: OfferingsSourceFor = (
95
+ organizationId: string,
96
+ ): OfferingsCatalogSource => ({
97
+ async listActiveCatalog(): Promise<OfferingsCatalog> {
98
+ let aborted = false;
99
+ const isAborted = () => aborted;
100
+ const read = (async (): Promise<OfferingsCatalog> => {
101
+ const connection = await stripeClientForOrganization(organizationId);
102
+ if (connection.kind !== "ok") throw new OfferingsCatalogUnavailableError();
103
+ const { client } = connection;
104
+ const products = pageAll(
105
+ (params) => client.products.list(params),
106
+ OFFERINGS_CATALOG_PRODUCTS_MAX,
107
+ isAborted,
108
+ );
109
+ const prices = pageAll((params) => client.prices.list(params), OFFERINGS_CATALOG_PRICES_MAX, isAborted);
110
+ // Each loop is caught on its own, so a page that rejects after the
111
+ // other loop or the timeout has failed the read is never unhandled.
112
+ products.catch(() => undefined);
113
+ prices.catch(() => undefined);
114
+ const [productPages, pricePages] = await Promise.all([products, prices]);
115
+ if (productPages.truncated || pricePages.truncated) {
116
+ logger.warn(
117
+ { organizationId, reason: "catalog_truncated" },
118
+ "[offerings] catalog over its read cap",
119
+ );
120
+ }
121
+ return { products: productPages.items, prices: pricePages.items };
122
+ })();
123
+ read.catch(() => undefined);
124
+
125
+ let timer: ReturnType<typeof setTimeout> | undefined;
126
+ const timeout = new Promise<never>((_resolve, reject) => {
127
+ timer = setTimeout(() => {
128
+ aborted = true;
129
+ reject(new OfferingsCatalogUnavailableError());
130
+ }, OFFERINGS_CATALOG_READ_TIMEOUT_MS);
131
+ });
132
+ try {
133
+ return await Promise.race([read, timeout]);
134
+ } finally {
135
+ clearTimeout(timer);
136
+ // A loop still running after the other one failed starts no more calls.
137
+ aborted = true;
138
+ }
139
+ },
140
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The Offerings sets and the snapshot (#8876, spec
3
+ * docs/specs/8876-the-offerings-data-model-typed-spec.md D6) — the one file
4
+ * naming the org-enabled set, the workspace selection and its snapshot, and,
5
+ * for this service, the workspace's Organization.
6
+ *
7
+ * Every argument is a server-resolved id, never a request value, except a
8
+ * set or selection handed to a writer, which its caller checked and the
9
+ * writer parses; and no function lists or enumerates. The selection read
10
+ * never names the snapshot, so checkout's allowlist (contract A4) can never
11
+ * read the display copy.
12
+ *
13
+ * This file writes the snapshot, only into a row that exists; for #8922, the
14
+ * org-enabled set; and, for #8926, the workspace selection and Recommended,
15
+ * creating its row.
16
+ */
17
+ import {
18
+ offeringsProductIdsSchema,
19
+ workspaceOfferingsSelectionSchema,
20
+ type OfferingsSnapshot,
21
+ } from "@working-theory/validation";
22
+
23
+ import { prisma } from "../../lib/prisma.js";
24
+
25
+ /**
26
+ * An Offerings result, or a set handed to a writer, failed its schema. The
27
+ * message is fixed; there is no cause. Re-exported by `index.ts`.
28
+ */
29
+ export class OfferingsShapeError extends Error {
30
+ constructor() {
31
+ super("An Offerings result failed its schema");
32
+ this.name = "OfferingsShapeError";
33
+ }
34
+ }
35
+
36
+ export interface WorkspaceSelection {
37
+ shownProductIds: string[];
38
+ /** The shown Prices (#9070 D1); empty for a row saved per Product. */
39
+ shownPriceIds: string[];
40
+ recommendedPlanProductId: string | null;
41
+ recommendedAddonProductId: string | null;
42
+ }
43
+
44
+ export interface WorkspaceSnapshotRow {
45
+ snapshot: unknown;
46
+ snapshotAt: Date | null;
47
+ }
48
+
49
+ /** The workspace's own Organization, or `null` for no workspace. */
50
+ export async function organizationIdOfWorkspace(workspaceId: string): Promise<string | null> {
51
+ const row = await prisma.workspace.findUnique({
52
+ where: { id: workspaceId },
53
+ select: { organizationId: true },
54
+ });
55
+ return row?.organizationId ?? null;
56
+ }
57
+
58
+ /** The org-enabled set; no row is the empty set. */
59
+ export async function readEnabledProductIds(organizationId: string): Promise<string[]> {
60
+ const row = await prisma.organizationOfferings.findUnique({
61
+ where: { organizationId },
62
+ select: { enabledProductIds: true },
63
+ });
64
+ return row?.enabledProductIds ?? [];
65
+ }
66
+
67
+ /**
68
+ * Replace the org-enabled set (#8922 D4), creating the row on an
69
+ * Organization's first save. Every set is parsed first — at most 100
70
+ * distinct Stripe ids — so the bound holds for every writer.
71
+ */
72
+ export async function writeEnabledProductIds(organizationId: string, productIds: string[]): Promise<void> {
73
+ const parsed = offeringsProductIdsSchema.safeParse(productIds);
74
+ if (!parsed.success) throw new OfferingsShapeError();
75
+ await prisma.organizationOfferings.upsert({
76
+ where: { organizationId },
77
+ create: { organizationId, enabledProductIds: parsed.data },
78
+ update: { enabledProductIds: parsed.data },
79
+ });
80
+ }
81
+
82
+ /** The shown set and Recommended, or `null` for no row. Never the snapshot. */
83
+ export async function readWorkspaceSelection(workspaceId: string): Promise<WorkspaceSelection | null> {
84
+ return prisma.workspaceOfferings.findUnique({
85
+ where: { workspaceId },
86
+ select: {
87
+ shownProductIds: true,
88
+ shownPriceIds: true,
89
+ recommendedPlanProductId: true,
90
+ recommendedAddonProductId: true,
91
+ },
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Replace the workspace's selection (#8926 D5), creating the row on its first
97
+ * save. Every selection is parsed first — at most 100 distinct Product ids
98
+ * and 300 Price ids, Prices exactly when Products (#9070 D4), each
99
+ * Recommended shown — so the database's CHECKs never fire and no new save is
100
+ * at the per-Product grain. Never names the snapshot, so a save keeps the
101
+ * last good copy.
102
+ */
103
+ export async function writeWorkspaceSelection(
104
+ workspaceId: string,
105
+ selection: WorkspaceSelection,
106
+ ): Promise<void> {
107
+ const parsed = workspaceOfferingsSelectionSchema.safeParse(selection);
108
+ if (!parsed.success) throw new OfferingsShapeError();
109
+ const { shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId } =
110
+ parsed.data;
111
+ await prisma.workspaceOfferings.upsert({
112
+ where: { workspaceId },
113
+ create: { workspaceId, shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId },
114
+ update: { shownProductIds, shownPriceIds, recommendedPlanProductId, recommendedAddonProductId },
115
+ });
116
+ }
117
+
118
+ /** The last-good snapshot and its time, or `null` for no row. */
119
+ export async function readWorkspaceSnapshot(workspaceId: string): Promise<WorkspaceSnapshotRow | null> {
120
+ return prisma.workspaceOfferings.findUnique({
121
+ where: { workspaceId },
122
+ select: { snapshot: true, snapshotAt: true },
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Write a parsed snapshot and its time together (the pairing CHECK). Creates
128
+ * no row, and never replaces a snapshot dated after `at` (unless it is dated
129
+ * more than `futureSkewMs` past it, which no reader serves): `false` when
130
+ * nothing was written.
131
+ */
132
+ export async function writeWorkspaceSnapshot(
133
+ workspaceId: string,
134
+ snapshot: OfferingsSnapshot,
135
+ at: Date,
136
+ futureSkewMs: number,
137
+ ): Promise<boolean> {
138
+ const { count } = await prisma.workspaceOfferings.updateMany({
139
+ where: {
140
+ workspaceId,
141
+ OR: [
142
+ { snapshotAt: null },
143
+ { snapshotAt: { lte: at } },
144
+ { snapshotAt: { gt: new Date(at.getTime() + futureSkewMs) } },
145
+ ],
146
+ },
147
+ data: { snapshot, snapshotAt: at },
148
+ });
149
+ return count > 0;
150
+ }
@@ -19,12 +19,21 @@
19
19
  * suites that pin that outcome.
20
20
  * - `resolveMemberPersonId` — the member reads' Person (#7877 D8): the one
21
21
  * linked to the caller, else the one whose email is the caller's
22
- * verified email, linked on first read by `stampPersonUser`'s rule.
22
+ * verified email, linked on first read by `stampPersonUser`'s rule. That
23
+ * first link — and no other — releases the Person's held checkout plan
24
+ * (#8793 D2).
23
25
  * - `resolveCallerBillingCustomerId` — the customer id the hub vouches for
24
26
  * in `GET /v1/hub-sign-in/me` (#7961, spec 7894 D13): that same Person's,
25
27
  * only while its `userId` is the caller.
26
28
  * - `setPersonPlanOffering` — the plan's only writer (#7877 D8), called by
27
- * the Admin's plan route and the checkout-lead rule in `lead-routing.ts`.
29
+ * the Admin's plan route and `releaseHeldCheckoutPlan`.
30
+ * - `releaseHeldCheckoutPlan` and `checkoutPlanKey` — the checkout plan
31
+ * rule (#7877 D10, #8793 D1–D4). A spoke's checkout lead never writes a
32
+ * plan: it holds it (`lead-routing.ts`), because its email is whatever
33
+ * the buyer typed at Stripe Checkout. The hold is released once, at the
34
+ * Person's first member-path link to the user whose VERIFIED email is the
35
+ * Person's own, into a blank plan only, and only when every held purchase
36
+ * under that email names the same plan key.
28
37
  * - the contact details (#8435): `findOrCreatePerson` fills BLANK contact
29
38
  * fields of an uncurated Person (D4); `updatePersonContact` (the
30
39
  * operator's save), `ensureMemberPerson` and `setOwnPersonContact` (the
@@ -53,7 +62,8 @@
53
62
  *
54
63
  * The base-tree `lead-routing.ts` imports this file, so it ships in every
55
64
  * build and must never reach the paid import closure: it imports
56
- * `lib/prisma.js`, `verified-email.js` and types only — never
65
+ * `lib/prisma.js`, `verified-email.js`, `planOfferingSchema` from the
66
+ * base-tree kernel `@working-theory/validation` and types only — never
57
67
  * `lead-promotion.ts`, `owner-workspaces.ts`, `lead-accept-user.ts` (whose
58
68
  * find-or-create shape it mirrors) or `lib/tenant-db.ts`.
59
69
  * `packages/create-ailk/__tests__/paid-module-exclusions.test.ts` holds this
@@ -62,7 +72,11 @@
62
72
  * ## Tenancy
63
73
  *
64
74
  * Every `Person` statement is scoped by a server-resolved `workspaceId`;
65
- * there is no list read here (the reads are `tenantDb(tenant).people`).
75
+ * there is no `Person` list read here (the reads are
76
+ * `tenantDb(tenant).people`). The one list read is `releaseHeldCheckoutPlan`'s
77
+ * (#8793 D3): the newest 51 `spokeCheckout` leads of a Person just linked to
78
+ * the caller, by `(workspaceId, personId)`, the workspace the member read
79
+ * resolved.
66
80
  * `recordCheckoutPerson`'s two other reads are by a server-minted
67
81
  * `FlowCheckout` id from a signature-verified webhook, matched on that
68
82
  * session's own `stripeSessionId`, then that row's lead scoped by the row's
@@ -84,7 +98,7 @@
84
98
 
85
99
  import type { Prisma } from "@working-theory/database";
86
100
  import type { Id } from "@working-theory/ids";
87
- import type { PersonContactPatch } from "@working-theory/validation";
101
+ import { planOfferingSchema, type PersonContactPatch } from "@working-theory/validation";
88
102
 
89
103
  import { prisma } from "../lib/prisma.js";
90
104
 
@@ -455,6 +469,29 @@ function usableCustomerId(raw: string | null | undefined): string | null {
455
469
  : null;
456
470
  }
457
471
 
472
+ /** A Stripe customer id: `cus_` and letters or digits only, no separator. */
473
+ const STRIPE_CUSTOMER_ID = /^cus_[A-Za-z0-9]+$/;
474
+
475
+ /**
476
+ * The Stripe Dashboard link to one customer (#9349 DD5). Pure: the caller
477
+ * reads the mode. `null` unless `customerId` is a well-formed `cus_` id of
478
+ * at most 255 characters, so a stored value can never add a path segment,
479
+ * a query or a fragment to the fixed Dashboard host.
480
+ */
481
+ export function stripeCustomerDashboardUrl(
482
+ customerId: string,
483
+ mode: "test" | "live",
484
+ ): string | null {
485
+ if (
486
+ customerId.length > MAX_CUSTOMER_ID_LENGTH ||
487
+ !STRIPE_CUSTOMER_ID.test(customerId)
488
+ ) {
489
+ return null;
490
+ }
491
+ const segment = mode === "test" ? "test/customers" : "customers";
492
+ return `https://dashboard.stripe.com/${segment}/${customerId}`;
493
+ }
494
+
458
495
  // ─── findOrCreatePerson ───────────────────────────────────────────────────────
459
496
 
460
497
  /** The one delegate this file writes through — the shared client or a transaction's. */
@@ -872,6 +909,14 @@ export async function resolveCheckoutPerson(input: {
872
909
  * every other stamp outcome, and a store error, is `null` too, logged by its
873
910
  * kind only. Never throws.
874
911
  *
912
+ * A fresh `stamped` link — and only that — releases the Person's held
913
+ * checkout plan (#8793 D2, `releaseHeldCheckoutPlan`): the caller has just
914
+ * proven they own the email the purchases were made under. A Person linked
915
+ * earlier, a refused stamp and every other caller of `stampPersonUser` (the
916
+ * Inbox accept above all) release nothing. The release never changes the
917
+ * answer; an `ambiguous` or `internal_error` release is logged by its kind
918
+ * only, as `{ event: "checkout-plan-release", outcome }`.
919
+ *
875
920
  * @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
876
921
  * @param userId the authenticated caller.
877
922
  * @param verifiedEmail the caller's verified address, read by the caller.
@@ -902,7 +947,19 @@ export async function resolveMemberPersonId(
902
947
  if (!byEmail) return null;
903
948
 
904
949
  const outcome = await stampPersonUser(workspaceId, byEmail.id, userId);
905
- if (outcome === "stamped" || outcome === "already_linked") return byEmail.id;
950
+ if (outcome === "stamped") {
951
+ const release = await releaseHeldCheckoutPlan(
952
+ workspaceId,
953
+ byEmail.id,
954
+ userId,
955
+ normalizePersonEmail(verifiedEmail),
956
+ );
957
+ if (release === "ambiguous" || release === "internal_error") {
958
+ log?.warn({ event: "checkout-plan-release", outcome: release }, "[person] held checkout plan not released");
959
+ }
960
+ return byEmail.id;
961
+ }
962
+ if (outcome === "already_linked") return byEmail.id;
906
963
  log?.warn({ event: "member-person-stamp", outcome }, "[person] member link refused");
907
964
  return null;
908
965
  }
@@ -943,17 +1000,28 @@ export type SetPlanOfferingOutcome = "set" | "not_found" | "internal_error";
943
1000
 
944
1001
  /**
945
1002
  * Set — or, with `null`, clear — a Person's plan: the ONE writer of
946
- * `Person.planOffering`. One `updateMany` scoped by the server-resolved
947
- * workspace; no row → `not_found`. Never throws.
1003
+ * `Person.planOffering`, called by the Admin's plan route and the held
1004
+ * checkout plan's release (#8793). One `updateMany` scoped by the
1005
+ * server-resolved workspace; no row → `not_found`. Never throws.
1006
+ *
1007
+ * With a `guard` (#8793 D5, the release's own write) the statement also
1008
+ * requires a blank plan and the Person still linked to `guard.linkedUserId`,
1009
+ * so a plan already set — or one the operator PUTs at the same moment — wins
1010
+ * and the answer is `not_found`. Without one it is the operator's overwrite.
948
1011
  */
949
1012
  export async function setPersonPlanOffering(
950
1013
  workspaceId: string,
951
1014
  personId: string,
952
1015
  planOffering: string | null,
1016
+ guard?: { linkedUserId: string },
953
1017
  ): Promise<SetPlanOfferingOutcome> {
954
1018
  try {
955
1019
  const { count } = await prisma.person.updateMany({
956
- where: { id: personId, workspaceId },
1020
+ where: {
1021
+ id: personId,
1022
+ workspaceId,
1023
+ ...(guard !== undefined && { planOffering: null, userId: guard.linkedUserId }),
1024
+ },
957
1025
  data: { planOffering },
958
1026
  });
959
1027
  return count === 0 ? "not_found" : "set";
@@ -962,6 +1030,76 @@ export async function setPersonPlanOffering(
962
1030
  }
963
1031
  }
964
1032
 
1033
+ // ─── The held checkout plan (#8793 D3, D4) ───────────────────────────────────
1034
+
1035
+ /**
1036
+ * A spoke checkout's plan key: the offering minus a leading `<site>_` (when
1037
+ * the site is a string), answered only when `planOfferingSchema` — the key
1038
+ * the operator's PUT accepts — admits it; else `null`. Shared by the
1039
+ * checkout's hold (`lead-routing.ts`) and the release below.
1040
+ */
1041
+ export function checkoutPlanKey(offering: unknown, site: unknown): string | null {
1042
+ if (typeof offering !== "string") return null;
1043
+ const prefix = typeof site === "string" ? `${site}_` : null;
1044
+ const key = prefix !== null && offering.startsWith(prefix) ? offering.slice(prefix.length) : offering;
1045
+ return planOfferingSchema.safeParse(key).success ? key : null;
1046
+ }
1047
+
1048
+ export type HeldPlanOutcome = "set" | "none" | "ambiguous" | "kept" | "internal_error";
1049
+
1050
+ /** The most held purchases a release weighs; one more is `ambiguous`. */
1051
+ const HELD_PURCHASES_READ = 50;
1052
+
1053
+ /**
1054
+ * Release a Person's held checkout plan (#8793 D3) — called only by
1055
+ * `resolveMemberPersonId` on a fresh `stamped` link, so `verifiedEmail` (the
1056
+ * caller's, normalized) is the Person's own. The Person's marked spoke
1057
+ * checkout leads (`Lead.spokeCheckout`, newest 51) under that email are taken
1058
+ * as a SET: one distinct plan key is written to a blank plan through the
1059
+ * guarded `setPersonPlanOffering` (`set`, or `kept` when the guard matched
1060
+ * nothing — a plan was already there, or the Person is no longer linked to
1061
+ * `userId`); no key is `none`; more than one key, or more than 50 leads, is
1062
+ * `ambiguous` and writes nothing — the operator sets the plan. So a
1063
+ * stranger's purchase under the owner's email can deny the automatic plan,
1064
+ * never choose it (SEC-8793-A). A store error is `internal_error`. Never
1065
+ * throws, and logs nothing: the caller logs the kind.
1066
+ */
1067
+ export async function releaseHeldCheckoutPlan(
1068
+ workspaceId: string,
1069
+ personId: string,
1070
+ userId: string,
1071
+ verifiedEmail: string,
1072
+ ): Promise<HeldPlanOutcome> {
1073
+ try {
1074
+ const rows = await prisma.lead.findMany({
1075
+ where: { workspaceId, personId, spokeCheckout: true },
1076
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
1077
+ take: HELD_PURCHASES_READ + 1,
1078
+ select: { email: true, payload: true },
1079
+ });
1080
+ if (rows.length > HELD_PURCHASES_READ) return "ambiguous";
1081
+
1082
+ const keys = new Set<string>();
1083
+ for (const row of rows) {
1084
+ // The purchase read's own rule: a purchase is its Person's only under
1085
+ // the Person's own email.
1086
+ if (normalizePersonEmail(row.email) !== verifiedEmail) continue;
1087
+ const payload = (row.payload ?? {}) as Record<string, unknown>;
1088
+ const key = checkoutPlanKey(payload["offering"], payload["site"]);
1089
+ if (key !== null) keys.add(key);
1090
+ }
1091
+ if (keys.size > 1) return "ambiguous";
1092
+ const [key] = keys;
1093
+ if (key === undefined) return "none";
1094
+
1095
+ const written = await setPersonPlanOffering(workspaceId, personId, key, { linkedUserId: userId });
1096
+ if (written === "set") return "set";
1097
+ return written === "not_found" ? "kept" : "internal_error";
1098
+ } catch {
1099
+ return "internal_error";
1100
+ }
1101
+ }
1102
+
965
1103
  // ─── The two human edits of the contact (#8435 D9, D11) ───────────────────────
966
1104
 
967
1105
  export type SavedContact = {
@@ -5,15 +5,29 @@
5
5
  * (`services/magnet-completion.ts`) read the same list `GET …/plan-offerings`
6
6
  * serves.
7
7
  *
8
- * The workspace's OWN Service then Product pages: `key` is the page slug's
9
- * last segment when it is an offering key (else the page is skipped), the
10
- * first page per key wins, at most 200; `name` is the page title. Off the
11
- * database content adapter only the default workspace has content
12
- * (`workspaceContentAdapter`), so every other workspace reads `[]`.
8
+ * Two sources (#8934, spec
9
+ * docs/specs/8934-re-source-the-plan-offerings-spec.md D1–D4):
13
10
  *
14
- * Never throws: a failed content read answers `{ failed }` with the error's
15
- * class name only, so a caller can tell "none published" from "could not
16
- * tell" — a failure is never a pass.
11
+ * - A connected workspace with the `commerce` claim lists its Offerings
12
+ * shown set (`getWorkspaceShownSet`, read from the database and never from
13
+ * Stripe — contract T7): one item per sellable Price, in the reader's
14
+ * order, `key` the Price's full lookup key (G5), `name` the product name
15
+ * and its billing word, `type` the product's group. The page list is not
16
+ * consulted.
17
+ * - An unclaimed workspace, or one the reader answers `null` for (no
18
+ * workspace, no connection, no selection), lists its OWN Service then
19
+ * Product pages: `key` is the page slug's last segment when it is an
20
+ * offering key (else the page is skipped), the first page per key wins, at
21
+ * most 200; `name` is the page title. Off the database content adapter only
22
+ * the default workspace has content (`workspaceContentAdapter`), so every
23
+ * other workspace reads `[]`.
24
+ *
25
+ * Never throws: a failed read answers `{ failed }` with a fixed word or the
26
+ * error's class name only, so a caller can tell "none listed" from "could not
27
+ * tell" — a failure is never a pass. `LicenseUndetermined` (the licence could
28
+ * not be read, so the Offerings services may not be called — 8876 D10),
29
+ * `OfferingsSnapshotExpired` and `OfferingsSnapshotEmpty` (the rows say what
30
+ * is shown, but no current snapshot says which keys it sells).
17
31
  *
18
32
  * Base tree (SEC-8757-D): no `oss: false` marker and no `MODULE_SLICES`
19
33
  * entry, because the never-gate lead route reaches it.
@@ -24,36 +38,88 @@ import {
24
38
  type PlanOfferingItem,
25
39
  } from "@working-theory/validation";
26
40
 
41
+ import { resolveCommerceClaim } from "../lib/require-commerce-claim.js";
42
+
43
+ import { getWorkspaceShownSet } from "./offerings/index.js";
27
44
  import { workspaceContentAdapter } from "./public-site-resolver.js";
28
45
 
29
- /** The workspace's Service then Product pages as plan keys (D9). */
46
+ /** `planOfferingItemSchema.name`'s bound, in UTF-16 units. */
47
+ const NAME_MAX = 200;
48
+
49
+ /** The workspace's offerings as plan keys (D3). */
30
50
  export async function listPlanOfferings(
31
51
  workspaceId: string,
32
52
  ): Promise<PlanOfferingItem[] | { failed: string }> {
33
53
  try {
34
- const adapter = await workspaceContentAdapter(workspaceId);
35
- if (!adapter) return [];
36
- const [services, products] = await Promise.all([
37
- adapter.listPages("Service"),
38
- adapter.listPages("Product"),
39
- ]);
40
- const offerings: PlanOfferingItem[] = [];
41
- const seen = new Set<string>();
42
- const typed = [
43
- ...services.map((page) => ({ page, type: "service" as const })),
44
- ...products.map((page) => ({ page, type: "product" as const })),
45
- ];
46
- for (const { page, type } of typed) {
47
- if (offerings.length >= PLAN_OFFERINGS_MAX) break;
48
- const key = page.slug.split("/").filter(Boolean).pop() ?? "";
49
- const name = page.title.trim().slice(0, 200);
50
- if (!planOfferingSchema.safeParse(key).success || name === "" || seen.has(key)) continue;
51
- seen.add(key);
52
- offerings.push({ key, name, type });
53
- }
54
- return offerings;
54
+ const claim = await resolveCommerceClaim();
55
+ if (claim === "undetermined") return { failed: "LicenseUndetermined" };
56
+ if (claim === "unclaimed") return await pageOfferings(workspaceId);
57
+ const shown = await getWorkspaceShownSet(workspaceId);
58
+ if (shown === null) return await pageOfferings(workspaceId);
59
+ if (shown.state === "expired") return { failed: "OfferingsSnapshotExpired" };
60
+ if (shown.state === "empty") return { failed: "OfferingsSnapshotEmpty" };
61
+ return collect(
62
+ shown.offerings.flatMap((offering) =>
63
+ offering.prices.map((price) => ({
64
+ key: price.lookupKey,
65
+ name: planOfferingName(offering.name, price.interval),
66
+ type: offering.ailkGroup,
67
+ })),
68
+ ),
69
+ );
55
70
  } catch (error) {
56
71
  // The error's class name only — never its message, which may name a slug.
57
72
  return { failed: error instanceof Error ? error.name : typeof error };
58
73
  }
59
74
  }
75
+
76
+ /** The workspace's Service then Product pages as plan keys (D4, #8514 D9). */
77
+ async function pageOfferings(workspaceId: string): Promise<PlanOfferingItem[]> {
78
+ const adapter = await workspaceContentAdapter(workspaceId);
79
+ if (!adapter) return [];
80
+ const [services, products] = await Promise.all([
81
+ adapter.listPages("Service"),
82
+ adapter.listPages("Product"),
83
+ ]);
84
+ const typed = [
85
+ ...services.map((page) => ({ page, type: "service" as const })),
86
+ ...products.map((page) => ({ page, type: "product" as const })),
87
+ ];
88
+ return collect(
89
+ typed.map(({ page, type }) => ({
90
+ key: page.slug.split("/").filter(Boolean).pop() ?? "",
91
+ name: page.title.trim().slice(0, NAME_MAX),
92
+ type,
93
+ })),
94
+ );
95
+ }
96
+
97
+ /** The guards both sources share: a valid key, a name, each key once, at most 200. */
98
+ function collect(candidates: PlanOfferingItem[]): PlanOfferingItem[] {
99
+ const offerings: PlanOfferingItem[] = [];
100
+ const seen = new Set<string>();
101
+ for (const { key, name, type } of candidates) {
102
+ if (offerings.length >= PLAN_OFFERINGS_MAX) break;
103
+ if (!planOfferingSchema.safeParse(key).success || name === "" || seen.has(key)) continue;
104
+ seen.add(key);
105
+ offerings.push({ key, name, type });
106
+ }
107
+ return offerings;
108
+ }
109
+
110
+ /**
111
+ * `<product name> (<word>)`, the word from the Price's interval (D2). Whole
112
+ * code points are dropped from the end of the product name until the name
113
+ * fits `NAME_MAX` units, so a surrogate pair is never split; the word is
114
+ * always kept.
115
+ */
116
+ function planOfferingName(productName: string, interval: "month" | "year" | null): string {
117
+ const word = interval === "month" ? "Monthly" : interval === "year" ? "Annual" : "One-time";
118
+ const suffix = ` (${word})`;
119
+ let name = "";
120
+ for (const point of productName) {
121
+ if (name.length + point.length + suffix.length > NAME_MAX) break;
122
+ name += point;
123
+ }
124
+ return name + suffix;
125
+ }