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,762 @@
1
+ /**
2
+ * @file shown-offerings.test.ts
3
+ * GET /v1/content/shown-offerings — the `/pricing` page's Offerings read
4
+ * (#8924, spec docs/specs/8924-the-anonymous-pricing-read-the-spec.md
5
+ * AC-1 to AC-6).
6
+ *
7
+ * The route runs on a bare Fastify with a log stream, over #8876's real
8
+ * services, `createInMemoryPrisma()` and the D9 seed at `FIXTURE_NOW`. The
9
+ * resolver and the commerce claim are mocked; the services module is wrapped
10
+ * by a counting factory whose two readers are `jest.fn`s over the real ones,
11
+ * so a case can count, spy or fail them; the `@working-theory/observability`
12
+ * logger the services write through is captured beside the request log.
13
+ */
14
+ import * as fs from "fs";
15
+ import * as path from "path";
16
+ import { Writable } from "node:stream";
17
+
18
+ import { workspaceShownSetSchema } from "@working-theory/validation";
19
+ import Fastify from "fastify";
20
+ import type { FastifyInstance } from "fastify";
21
+
22
+ /** While `watching`, every key read from the services module, `__esModule` included. */
23
+ const mockOfferingsModule = { watching: false, reads: [] as string[] };
24
+ const mockLog = jest.fn();
25
+ const mockResolvePublicWorkspace = jest.fn();
26
+ const mockResolveCommerceClaim = jest.fn();
27
+
28
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); the in-memory double is the repo-standard where-honouring test double for it.
29
+ jest.mock("../../../lib/prisma.js", () =>
30
+ jest
31
+ .requireActual<
32
+ typeof import("../../../__tests__/factories/in-memory-prisma")
33
+ >("../../../__tests__/factories/in-memory-prisma")
34
+ .createInMemoryPrisma(),
35
+ );
36
+
37
+ jest.mock("@working-theory/observability", () => ({
38
+ logger: {
39
+ error: (...args: unknown[]) => mockLog("error", ...args),
40
+ warn: (...args: unknown[]) => mockLog("warn", ...args),
41
+ info: (...args: unknown[]) => mockLog("info", ...args),
42
+ debug: (...args: unknown[]) => mockLog("debug", ...args),
43
+ },
44
+ }));
45
+
46
+ // eslint-disable-next-line no-restricted-syntax -- the resolver reads the spoke registry and the content adapter; this suite's job is the route's own arms
47
+ jest.mock("../../../services/public-site-resolver.js", () => ({
48
+ __esModule: true,
49
+ resolvePublicWorkspace: (...args: unknown[]) =>
50
+ mockResolvePublicWorkspace(...args),
51
+ resolvePublicContentAdapter: jest.fn(),
52
+ }));
53
+
54
+ // eslint-disable-next-line no-restricted-syntax -- the claim reads the licence (an external JWT boundary)
55
+ jest.mock("../../../lib/require-commerce-claim.js", () => ({
56
+ __esModule: true,
57
+ resolveCommerceClaim: () => mockResolveCommerceClaim(),
58
+ }));
59
+
60
+ // The real services behind a read recorder, with the two readers the route
61
+ // calls wrapped in `jest.fn` so a case can spy on or fail them. The `import()`
62
+ // interop reads the module's `__esModule`, so a case that watches the module
63
+ // sees the route's late import itself, not only a call.
64
+ // eslint-disable-next-line no-restricted-syntax -- not a stand-in: the real module behind a read recorder, which is how AC-2 proves the route touches it only after the claim reads `claimed`
65
+ jest.mock("../../../services/offerings/index.js", () => {
66
+ const actual = jest.requireActual<
67
+ typeof import("../../../services/offerings/index.js")
68
+ >("../../../services/offerings/index.js");
69
+ const exports: Record<string, unknown> = {
70
+ ...actual,
71
+ getWorkspaceShownSet: jest.fn(actual.getWorkspaceShownSet),
72
+ refreshWorkspaceSnapshot: jest.fn(actual.refreshWorkspaceSnapshot),
73
+ };
74
+ return new Proxy(exports, {
75
+ get(target, key) {
76
+ if (mockOfferingsModule.watching && typeof key === "string") {
77
+ mockOfferingsModule.reads.push(key);
78
+ }
79
+ return target[key as string];
80
+ },
81
+ });
82
+ });
83
+
84
+ import { prisma } from "../../../lib/prisma.js";
85
+ import {
86
+ getWorkspaceShownSet,
87
+ LAST_GOOD_MAX_AGE_MS,
88
+ refreshWorkspaceSnapshot,
89
+ resetOfferingsRefreshStateForTests,
90
+ type OfferingsSourceFor,
91
+ } from "../../../services/offerings/index.js";
92
+ import {
93
+ EXPECTED_SHOWN_SET,
94
+ FIXTURE_NOW,
95
+ FIXTURE_ORG,
96
+ FIXTURE_ORG_2_WS,
97
+ FIXTURE_WS,
98
+ FIXTURE_WS_2,
99
+ OFFERINGS_FIXTURE_CATALOG,
100
+ seedOfferings,
101
+ } from "../../../services/offerings/__tests__/fixtures.js";
102
+ import { contentRoutes } from "../index";
103
+ import {
104
+ SHOWN_OFFERINGS_REFRESH_BUDGET_MS,
105
+ shownOfferingsRoute,
106
+ type ShownOfferingsRouteOptions,
107
+ } from "../shown-offerings";
108
+
109
+ type Row = Record<string, unknown>;
110
+ // The in-memory double exposes its store and its recorded calls, which the
111
+ // PrismaClient type the import carries does not name, so it is named here once.
112
+ // eslint-disable-next-line no-restricted-syntax -- justified above
113
+ const db = prisma as unknown as {
114
+ __db: Record<string, Row[]>;
115
+ __calls: Array<{ model: string; op: string }>;
116
+ };
117
+
118
+ const mockGetShownSet = jest.mocked(getWorkspaceShownSet);
119
+ const mockRefresh = jest.mocked(refreshWorkspaceSnapshot);
120
+
121
+ const SECOND = 1000;
122
+ const at = (offsetMs: number) => new Date(FIXTURE_NOW.getTime() + offsetMs);
123
+
124
+ const ROUTE_FILE = path.join(__dirname, "..", "shown-offerings.ts");
125
+ const REPO_ROOT = path.join(__dirname, "..", "..", "..", "..", "..", "..");
126
+
127
+ /** A `sourceFor` that answers the fixture catalog and counts each source call. */
128
+ function countingSourceFor(
129
+ answer: () => Promise<typeof OFFERINGS_FIXTURE_CATALOG> = async () =>
130
+ structuredClone(OFFERINGS_FIXTURE_CATALOG),
131
+ ) {
132
+ const calls: string[] = [];
133
+ const sourceFor: OfferingsSourceFor = (organizationId) => ({
134
+ listActiveCatalog: () => {
135
+ calls.push(organizationId);
136
+ return answer();
137
+ },
138
+ });
139
+ return { sourceFor, calls };
140
+ }
141
+
142
+ /** Waits, a macrotask at a time, until `done()` holds (at most a second). */
143
+ async function until(done: () => boolean): Promise<void> {
144
+ for (let i = 0; i < 200 && !done(); i += 1) {
145
+ await new Promise((resolve) => setTimeout(resolve, 5));
146
+ }
147
+ }
148
+
149
+ let logLines: string[] = [];
150
+ let apps: FastifyInstance[] = [];
151
+
152
+ /** The suite's Fastify, with `Vary: Origin` written before the route, as CORS does. */
153
+ async function buildApp(
154
+ options: ShownOfferingsRouteOptions = {},
155
+ ): Promise<FastifyInstance> {
156
+ const stream = new Writable({
157
+ write(chunk: Buffer, _encoding, callback) {
158
+ logLines.push(chunk.toString());
159
+ callback();
160
+ },
161
+ });
162
+ const app = Fastify({ logger: { level: "trace", stream } });
163
+ app.addHook("onRequest", async (_request, reply) => {
164
+ reply.header("Vary", "Origin");
165
+ });
166
+ await app.register(shownOfferingsRoute, options);
167
+ apps.push(app);
168
+ return app;
169
+ }
170
+
171
+ async function get(app: FastifyInstance, url = "/v1/content/shown-offerings") {
172
+ const response = await app.inject({ method: "GET", url });
173
+ return { response, body: JSON.parse(response.body) as Row };
174
+ }
175
+
176
+ function resolvedTo(workspaceId: string): void {
177
+ mockResolvePublicWorkspace.mockResolvedValue({
178
+ kind: "resolved",
179
+ workspaceId,
180
+ });
181
+ }
182
+
183
+ /** Every line the request log and the services' logger wrote in this case. */
184
+ function capturedLogText(): string {
185
+ return [
186
+ ...logLines,
187
+ ...mockLog.mock.calls.map((call) => JSON.stringify(call)),
188
+ ].join("\n");
189
+ }
190
+
191
+ /** Lines of the request log whose `outcome` is `outcome`. */
192
+ function linesWithOutcome(outcome: string): Row[] {
193
+ return logLines
194
+ .map((line) => JSON.parse(line) as Row)
195
+ .filter((line) => line.outcome === outcome);
196
+ }
197
+
198
+ /** Every product name, description, feature string and id in the fixture catalog. */
199
+ const FIXTURE_TEXT: string[] = [
200
+ ...OFFERINGS_FIXTURE_CATALOG.products.flatMap((product) => [
201
+ product.name,
202
+ ...(product.description ? [product.description] : []),
203
+ ...(product.marketing_features ?? []).map((feature) => feature.name),
204
+ product.id,
205
+ ]),
206
+ ...OFFERINGS_FIXTURE_CATALOG.prices.map((price) => price.id),
207
+ "prod_",
208
+ "price_",
209
+ ];
210
+
211
+ beforeEach(async () => {
212
+ for (const key of Object.keys(db.__db)) delete db.__db[key];
213
+ logLines = [];
214
+ mockLog.mockClear();
215
+ mockGetShownSet.mockClear();
216
+ mockRefresh.mockClear();
217
+ mockResolvePublicWorkspace.mockReset();
218
+ mockResolveCommerceClaim.mockReset().mockResolvedValue("claimed");
219
+ resetOfferingsRefreshStateForTests();
220
+ await seedOfferings(prisma, { now: FIXTURE_NOW });
221
+ db.__calls.length = 0;
222
+ resolvedTo(FIXTURE_WS);
223
+ });
224
+
225
+ afterEach(async () => {
226
+ // AC-6 (T6): across every case, no log line carries Stripe text.
227
+ const text = capturedLogText();
228
+ for (const fragment of FIXTURE_TEXT) expect(text).not.toContain(fragment);
229
+ await Promise.all(apps.map((app) => app.close()));
230
+ apps = [];
231
+ });
232
+
233
+ describe("the three answers (AC-1)", () => {
234
+ it("serves a resolved, connected workspace its shown set, the five fields the schema parses", async () => {
235
+ const app = await buildApp({ now: () => FIXTURE_NOW });
236
+ const { response, body } = await get(app);
237
+ expect(response.statusCode).toBe(200);
238
+ const data = body.data as { status: string; shownSet: Row };
239
+ expect(body.ok).toBe(true);
240
+ expect(data.status).toBe("shown");
241
+ expect(Object.keys(data.shownSet).sort()).toEqual([
242
+ "offerings",
243
+ "productIds",
244
+ "recommended",
245
+ "snapshotAt",
246
+ "state",
247
+ ]);
248
+ expect(
249
+ workspaceShownSetSchema.parse({
250
+ ...data.shownSet,
251
+ workspaceId: FIXTURE_WS,
252
+ organizationId: FIXTURE_ORG,
253
+ }),
254
+ ).toEqual(EXPECTED_SHOWN_SET);
255
+ });
256
+
257
+ it("answers an unresolved request the fail-closed empty answer, reading no Offerings data", async () => {
258
+ mockResolvePublicWorkspace.mockResolvedValue({
259
+ kind: "unresolved",
260
+ reason: "spoke-refused",
261
+ });
262
+ const app = await buildApp();
263
+ const { response, body } = await get(app);
264
+ expect(response.statusCode).toBe(200);
265
+ expect(body).toEqual({ ok: true, data: { status: "empty" } });
266
+ expect(db.__calls).toEqual([]);
267
+ expect(mockGetShownSet).not.toHaveBeenCalled();
268
+ expect(mockResolveCommerceClaim).not.toHaveBeenCalled();
269
+ expect(linesWithOutcome("license_undetermined")).toEqual([]);
270
+ expect(
271
+ logLines
272
+ .map((line) => JSON.parse(line) as Row)
273
+ .filter((line) => line.reason === "spoke-refused"),
274
+ ).toHaveLength(1);
275
+ });
276
+
277
+ it("answers not-required (a non-database content adapter) unconnected", async () => {
278
+ mockResolvePublicWorkspace.mockResolvedValue({ kind: "not-required" });
279
+ const app = await buildApp();
280
+ const { response, body } = await get(app);
281
+ expect(response.statusCode).toBe(200);
282
+ expect(body).toEqual({ ok: true, data: { status: "unconnected" } });
283
+ expect(db.__calls).toEqual([]);
284
+ });
285
+
286
+ it.each([
287
+ ["a workspace with no selection", FIXTURE_WS_2],
288
+ ["a workspace whose Organization has no connection", FIXTURE_ORG_2_WS],
289
+ ])(
290
+ "answers %s an explicit unconnected answer",
291
+ async (_name, workspaceId) => {
292
+ resolvedTo(workspaceId);
293
+ const app = await buildApp({ now: () => FIXTURE_NOW });
294
+ const { response, body } = await get(app);
295
+ expect(response.statusCode).toBe(200);
296
+ expect(body).toEqual({ ok: true, data: { status: "unconnected" } });
297
+ },
298
+ );
299
+
300
+ it("answers a resolver that throws 500 internal_error, with the fixed body", async () => {
301
+ mockResolvePublicWorkspace.mockRejectedValue(
302
+ new Error("database unreachable"),
303
+ );
304
+ const app = await buildApp();
305
+ const { response, body } = await get(app);
306
+ expect(response.statusCode).toBe(500);
307
+ expect(body).toEqual({
308
+ ok: false,
309
+ error: { code: "internal_error", message: "Internal server error" },
310
+ });
311
+ expect(capturedLogText()).not.toContain("database unreachable");
312
+ });
313
+ });
314
+
315
+ describe("the claim gates every Offerings read (AC-2)", () => {
316
+ it.each([
317
+ ["unclaimed", { status: "unconnected" }],
318
+ ["undetermined", { status: "empty" }],
319
+ ] as const)("a %s licence reads no Offerings data", async (claim, data) => {
320
+ mockResolveCommerceClaim.mockResolvedValue(claim);
321
+ const app = await buildApp();
322
+ const { response, body } = await get(app);
323
+ expect(response.statusCode).toBe(200);
324
+ expect(body).toEqual({ ok: true, data });
325
+ expect(db.__calls).toEqual([]);
326
+ expect(mockGetShownSet).not.toHaveBeenCalled();
327
+ expect(mockRefresh).not.toHaveBeenCalled();
328
+ expect(linesWithOutcome("license_undetermined")).toHaveLength(
329
+ claim === "undetermined" ? 1 : 0,
330
+ );
331
+ });
332
+
333
+ /** The keys the route reads from the services module during one request. */
334
+ async function servicesReadDuring(app: FastifyInstance): Promise<string[]> {
335
+ mockOfferingsModule.reads = [];
336
+ mockOfferingsModule.watching = true;
337
+ try {
338
+ await get(app);
339
+ } finally {
340
+ mockOfferingsModule.watching = false;
341
+ }
342
+ return mockOfferingsModule.reads;
343
+ }
344
+
345
+ it.each(["unclaimed", "undetermined"] as const)(
346
+ "a %s licence never imports the services module",
347
+ async (claim) => {
348
+ mockResolveCommerceClaim.mockResolvedValue(claim);
349
+ const app = await buildApp();
350
+ expect(await servicesReadDuring(app)).toEqual([]);
351
+ },
352
+ );
353
+
354
+ it("a claimed licence imports the services module only then", async () => {
355
+ const app = await buildApp({ now: () => FIXTURE_NOW });
356
+ expect(await servicesReadDuring(app)).toEqual(
357
+ expect.arrayContaining(["__esModule", "getWorkspaceShownSet"]),
358
+ );
359
+ });
360
+
361
+ it("the route file holds no static import of the services", () => {
362
+ const source = fs.readFileSync(ROUTE_FILE, "utf8");
363
+ expect(source).not.toMatch(/^import (?!type )[^;]*services\/offerings/m);
364
+ });
365
+
366
+ it("a claimed licence is served", async () => {
367
+ const app = await buildApp({ now: () => FIXTURE_NOW });
368
+ const { body } = await get(app);
369
+ expect((body.data as Row).status).toBe("shown");
370
+ });
371
+ });
372
+
373
+ describe("the refresh is bounded and never overrides the minute (AC-3, T7)", () => {
374
+ it("a snapshot 30 seconds old is served with no refresh", async () => {
375
+ const { sourceFor, calls } = countingSourceFor();
376
+ const app = await buildApp({ sourceFor, now: () => at(30 * SECOND) });
377
+ const { body } = await get(app);
378
+ expect((body.data as Row).status).toBe("shown");
379
+ expect(mockRefresh).not.toHaveBeenCalled();
380
+ expect(calls).toEqual([]);
381
+ });
382
+
383
+ it("five concurrent stale requests cost one catalog read, and a sixth 30 seconds later none", async () => {
384
+ // The catalog read is held open until all five requests have asked to
385
+ // refresh, so every one of them meets the read in flight.
386
+ let release!: () => void;
387
+ const held = new Promise<void>((resolve) => (release = resolve));
388
+ const { sourceFor, calls } = countingSourceFor(async () => {
389
+ await held;
390
+ return structuredClone(OFFERINGS_FIXTURE_CATALOG);
391
+ });
392
+ let now = at(61 * SECOND);
393
+ const app = await buildApp({ sourceFor, now: () => now });
394
+
395
+ const pending = Promise.all(Array.from({ length: 5 }, () => get(app)));
396
+ await until(() => mockRefresh.mock.calls.length === 5);
397
+ release();
398
+ const answers = await pending;
399
+ expect(calls).toEqual([FIXTURE_ORG]);
400
+ for (const { response, body } of answers) {
401
+ expect(response.statusCode).toBe(200);
402
+ const data = body.data as { status: string; shownSet: Row };
403
+ expect(data.status).toBe("shown");
404
+ expect(data.shownSet.snapshotAt).toBe(at(61 * SECOND).toISOString());
405
+ }
406
+
407
+ now = at(91 * SECOND);
408
+ const sixth = await get(app);
409
+ expect((sixth.body.data as Row).status).toBe("shown");
410
+ expect(calls).toEqual([FIXTURE_ORG]);
411
+
412
+ expect(mockRefresh).toHaveBeenCalledTimes(5);
413
+ for (const call of mockRefresh.mock.calls) {
414
+ expect(call[0]).toBe(FIXTURE_WS);
415
+ expect(call[2]).not.toHaveProperty("force");
416
+ }
417
+ });
418
+
419
+ it("the route file holds no force token", () => {
420
+ expect(fs.readFileSync(ROUTE_FILE, "utf8")).not.toMatch(/\bforce\b/);
421
+ });
422
+
423
+ it("registered with no sourceFor, a stale snapshot is served and never refreshed", async () => {
424
+ const app = await buildApp({ now: () => at(61 * SECOND) });
425
+ const { body } = await get(app);
426
+ const data = body.data as { status: string; shownSet: Row };
427
+ expect(data.status).toBe("shown");
428
+ expect(data.shownSet.snapshotAt).toBe(FIXTURE_NOW.toISOString());
429
+ expect(mockRefresh).not.toHaveBeenCalled();
430
+ });
431
+ });
432
+
433
+ describe("a failed refresh leaves the last good copy served (AC-4)", () => {
434
+ function storedSnapshot(): { snapshot: unknown; snapshotAt: unknown } {
435
+ const row = db.__db.workspaceOfferings!.find(
436
+ (r) => r.workspaceId === FIXTURE_WS,
437
+ )!;
438
+ return {
439
+ snapshot: structuredClone(row.snapshot),
440
+ snapshotAt: row.snapshotAt,
441
+ };
442
+ }
443
+
444
+ function expectSeededAnswer(body: Row): void {
445
+ const data = body.data as { status: string; shownSet: Row };
446
+ expect(data.status).toBe("shown");
447
+ expect(data.shownSet.offerings).toEqual(EXPECTED_SHOWN_SET.offerings);
448
+ expect(data.shownSet.snapshotAt).toBe(FIXTURE_NOW.toISOString());
449
+ }
450
+
451
+ it("a source that rejects serves the seeded snapshot and writes nothing", async () => {
452
+ const before = storedSnapshot();
453
+ const { sourceFor, calls } = countingSourceFor(async () => {
454
+ throw new Error("upstream failed");
455
+ });
456
+ const app = await buildApp({ sourceFor, now: () => at(61 * SECOND) });
457
+ const { response, body } = await get(app);
458
+ expect(response.statusCode).toBe(200);
459
+ expectSeededAnswer(body);
460
+ expect(calls).toEqual([FIXTURE_ORG]);
461
+ expect(storedSnapshot()).toEqual(before);
462
+ });
463
+
464
+ it(
465
+ "a source that never settles is answered within the budget, logged refresh_over_budget",
466
+ async () => {
467
+ const { sourceFor } = countingSourceFor(
468
+ () => new Promise(() => undefined),
469
+ );
470
+ const app = await buildApp({ sourceFor, now: () => at(61 * SECOND) });
471
+ const started = Date.now();
472
+ const { response, body } = await get(app);
473
+ expect(Date.now() - started).toBeLessThan(
474
+ SHOWN_OFFERINGS_REFRESH_BUDGET_MS + 500,
475
+ );
476
+ expect(response.statusCode).toBe(200);
477
+ expectSeededAnswer(body);
478
+ expect(linesWithOutcome("refresh_over_budget")).toEqual([
479
+ expect.objectContaining({ workspaceId: FIXTURE_WS }),
480
+ ]);
481
+ },
482
+ SHOWN_OFFERINGS_REFRESH_BUDGET_MS + 5000,
483
+ );
484
+
485
+ it("a refresh that rejects is logged refresh_failed with its class name", async () => {
486
+ mockRefresh.mockRejectedValueOnce(
487
+ new TypeError("Growth prod_fixture_growth"),
488
+ );
489
+ const { sourceFor } = countingSourceFor();
490
+ const app = await buildApp({ sourceFor, now: () => at(61 * SECOND) });
491
+ const { response, body } = await get(app);
492
+ expect(response.statusCode).toBe(200);
493
+ expectSeededAnswer(body);
494
+ expect(linesWithOutcome("refresh_failed")).toEqual([
495
+ expect.objectContaining({
496
+ workspaceId: FIXTURE_WS,
497
+ errName: "TypeError",
498
+ }),
499
+ ]);
500
+ });
501
+
502
+ it(
503
+ "a refresh that rejects after the budget is still caught and logged refresh_failed",
504
+ async () => {
505
+ mockRefresh.mockImplementationOnce(
506
+ () =>
507
+ new Promise((_resolve, reject) =>
508
+ setTimeout(
509
+ () => reject(new TypeError("late")),
510
+ SHOWN_OFFERINGS_REFRESH_BUDGET_MS + 100,
511
+ ),
512
+ ),
513
+ );
514
+ const { sourceFor } = countingSourceFor();
515
+ const app = await buildApp({ sourceFor, now: () => at(61 * SECOND) });
516
+ const { response, body } = await get(app);
517
+ expect(response.statusCode).toBe(200);
518
+ expectSeededAnswer(body);
519
+ expect(linesWithOutcome("refresh_over_budget")).toHaveLength(1);
520
+ expect(linesWithOutcome("refresh_failed")).toEqual([]);
521
+
522
+ // The rejection lands after the answer was sent: only a handler attached
523
+ // before the race sees it.
524
+ await until(() => linesWithOutcome("refresh_failed").length > 0);
525
+ expect(linesWithOutcome("refresh_failed")).toEqual([
526
+ expect.objectContaining({
527
+ workspaceId: FIXTURE_WS,
528
+ errName: "TypeError",
529
+ }),
530
+ ]);
531
+ },
532
+ SHOWN_OFFERINGS_REFRESH_BUDGET_MS + 5000,
533
+ );
534
+
535
+ it("a re-read that rejects after refreshed serves the first read, logged reread_failed", async () => {
536
+ const { sourceFor } = countingSourceFor();
537
+ const app = await buildApp({ sourceFor, now: () => at(61 * SECOND) });
538
+ mockGetShownSet
539
+ .mockImplementationOnce(mockGetShownSet.getMockImplementation()!)
540
+ .mockRejectedValueOnce(new RangeError("For a growing practice."));
541
+ const { response, body } = await get(app);
542
+ expect(response.statusCode).toBe(200);
543
+ expect(await mockRefresh.mock.results[0]!.value).toBe("refreshed");
544
+ expectSeededAnswer(body);
545
+ expect(linesWithOutcome("reread_failed")).toEqual([
546
+ expect.objectContaining({
547
+ workspaceId: FIXTURE_WS,
548
+ errName: "RangeError",
549
+ }),
550
+ ]);
551
+ });
552
+
553
+ it("a snapshot 25 hours old with a rejecting source is served as expired", async () => {
554
+ const { sourceFor } = countingSourceFor(async () => {
555
+ throw new Error("upstream failed");
556
+ });
557
+ const app = await buildApp({
558
+ sourceFor,
559
+ now: () => at(25 * 60 * 60 * SECOND),
560
+ });
561
+ const { response, body } = await get(app);
562
+ expect(response.statusCode).toBe(200);
563
+ const data = body.data as { status: string; shownSet: Row };
564
+ expect(data.status).toBe("shown");
565
+ expect(data.shownSet.state).toBe("expired");
566
+ expect(data.shownSet.offerings).toEqual([]);
567
+ });
568
+ });
569
+
570
+ describe("the production registration refreshes (#9124)", () => {
571
+ const SERVER_FILE = path.join(__dirname, "..", "..", "..", "server.ts");
572
+ const STALE_PAST_LAST_GOOD = LAST_GOOD_MAX_AGE_MS + 60 * 60 * SECOND;
573
+
574
+ // A text check, like the registration check below: building the whole
575
+ // server for one options object is out of proportion. It names no source:
576
+ // architecture.yaml confines that identifier to its reviewed callers.
577
+ it("server.ts registers the three shown-set routes with one options object that carries a sourceFor", () => {
578
+ const server = fs.readFileSync(SERVER_FILE, "utf8");
579
+ expect(server).toContain('from "./services/offerings/source.js";');
580
+ expect(server).toMatch(
581
+ /shownSetRouteOptions: ShownOfferingsRouteOptions = \{\s*sourceFor:/,
582
+ );
583
+ for (const route of [
584
+ "offeringsRoute",
585
+ "pricingRoute",
586
+ "shownOfferingsRoute",
587
+ ]) {
588
+ expect(server).toContain(
589
+ `await server.register(${route}, shownSetRouteOptions);`,
590
+ );
591
+ }
592
+ });
593
+
594
+ it("a snapshot past LAST_GOOD_MAX_AGE_MS refreshes through sourceFor and answers shown, not expired", async () => {
595
+ // The shape server.ts registers: a sourceFor and no other option.
596
+ const { sourceFor, calls } = countingSourceFor();
597
+ const options: ShownOfferingsRouteOptions = { sourceFor };
598
+ const now = at(STALE_PAST_LAST_GOOD);
599
+ const app = await buildApp({ ...options, now: () => now });
600
+ const { response, body } = await get(app);
601
+ expect(response.statusCode).toBe(200);
602
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
603
+ expect(mockRefresh.mock.calls[0]![0]).toBe(FIXTURE_WS);
604
+ expect(calls).toEqual([FIXTURE_ORG]);
605
+ const data = body.data as { status: string; shownSet: Row };
606
+ expect(data.status).toBe("shown");
607
+ expect(data.shownSet.state).toBe("current");
608
+ expect(data.shownSet.snapshotAt).toBe(now.toISOString());
609
+ expect(data.shownSet.offerings).not.toEqual([]);
610
+ });
611
+
612
+ it("the pre-fix registration (no sourceFor) answers the same snapshot expired and never refreshes", async () => {
613
+ const app = await buildApp({ now: () => at(STALE_PAST_LAST_GOOD) });
614
+ const { response, body } = await get(app);
615
+ expect(response.statusCode).toBe(200);
616
+ expect(mockRefresh).not.toHaveBeenCalled();
617
+ const data = body.data as { status: string; shownSet: Row };
618
+ expect(data.status).toBe("shown");
619
+ expect(data.shownSet.state).toBe("expired");
620
+ expect(data.shownSet.offerings).toEqual([]);
621
+ });
622
+ });
623
+
624
+ describe("the workspace is the resolver's alone (AC-5)", () => {
625
+ it("ignores a workspace named in the query and a header", async () => {
626
+ const app = await buildApp({ now: () => FIXTURE_NOW });
627
+ const response = await app.inject({
628
+ method: "GET",
629
+ url: `/v1/content/shown-offerings?workspaceId=${FIXTURE_ORG_2_WS}`,
630
+ headers: { "x-ailk-workspace-id": FIXTURE_ORG_2_WS },
631
+ });
632
+ const data = (JSON.parse(response.body) as Row).data as {
633
+ status: string;
634
+ shownSet: Row;
635
+ };
636
+ expect(data.status).toBe("shown");
637
+ expect(data.shownSet.productIds).toEqual(EXPECTED_SHOWN_SET.productIds);
638
+ expect(mockResolvePublicWorkspace).toHaveBeenCalledTimes(1);
639
+ const [request] = mockResolvePublicWorkspace.mock.calls[0] as [
640
+ { url: string },
641
+ ];
642
+ expect(request.url).toBe(
643
+ `/v1/content/shown-offerings?workspaceId=${FIXTURE_ORG_2_WS}`,
644
+ );
645
+ });
646
+
647
+ it("the route file reads no request input of its own", () => {
648
+ const source = fs.readFileSync(ROUTE_FILE, "utf8");
649
+ for (const input of [
650
+ "request.query",
651
+ "request.params",
652
+ "request.body",
653
+ "request.headers",
654
+ ]) {
655
+ expect(source).not.toContain(input);
656
+ }
657
+ });
658
+
659
+ it.each([
660
+ ["shown", () => resolvedTo(FIXTURE_WS)],
661
+ [
662
+ "empty",
663
+ () =>
664
+ mockResolvePublicWorkspace.mockResolvedValue({
665
+ kind: "unresolved",
666
+ reason: "ambiguous",
667
+ }),
668
+ ],
669
+ [
670
+ "unconnected",
671
+ () =>
672
+ mockResolvePublicWorkspace.mockResolvedValue({ kind: "not-required" }),
673
+ ],
674
+ [
675
+ "the 500",
676
+ () => mockResolvePublicWorkspace.mockRejectedValue(new Error("boom")),
677
+ ],
678
+ ])(
679
+ "%s carries x-ailk-spoke-key in Vary beside Origin",
680
+ async (_name, arrange) => {
681
+ arrange();
682
+ const app = await buildApp({ now: () => FIXTURE_NOW });
683
+ const { response } = await get(app);
684
+ const vary = String(response.headers.vary)
685
+ .split(",")
686
+ .map((value) => value.trim().toLowerCase());
687
+ expect(vary).toEqual(
688
+ expect.arrayContaining(["origin", "x-ailk-spoke-key"]),
689
+ );
690
+ expect(response.headers["cache-control"]).toBeUndefined();
691
+ },
692
+ );
693
+ });
694
+
695
+ describe("nothing leaks, and nothing else moves (AC-6, T6)", () => {
696
+ it("a source error naming a product and a key, and a reader error naming a description, reach no log or body", async () => {
697
+ const keyText = `rk_test_${"51Fixture8924Body".padEnd(24, "x")}`;
698
+ const { sourceFor } = countingSourceFor(async () => {
699
+ throw new Error(`Growth rejected ${keyText}`);
700
+ });
701
+ const app = await buildApp({ sourceFor, now: () => at(61 * SECOND) });
702
+ const failedSource = await get(app);
703
+ expect(failedSource.response.statusCode).toBe(200);
704
+
705
+ mockGetShownSet.mockRejectedValueOnce(new Error("One site, set up once."));
706
+ const failedReader = await get(app);
707
+ expect(failedReader.response.statusCode).toBe(500);
708
+ expect(failedReader.body).toEqual({
709
+ ok: false,
710
+ error: { code: "internal_error", message: "Internal server error" },
711
+ });
712
+
713
+ const text = capturedLogText();
714
+ for (const fragment of [
715
+ keyText,
716
+ "rk_test_",
717
+ "Growth rejected",
718
+ "One site, set up once.",
719
+ ]) {
720
+ expect(text).not.toContain(fragment);
721
+ expect(failedReader.response.body).not.toContain(fragment);
722
+ }
723
+ });
724
+
725
+ it("is served through contentRoutes with the D2 envelope, not a page list", async () => {
726
+ const app = Fastify();
727
+ apps.push(app);
728
+ await app.register(contentRoutes);
729
+ const response = await app.inject({
730
+ method: "GET",
731
+ url: "/v1/content/shown-offerings",
732
+ });
733
+ expect(response.statusCode).toBe(200);
734
+ const body = JSON.parse(response.body) as Row;
735
+ expect(body.ok).toBe(true);
736
+ expect((body.data as Row).status).toBe("shown");
737
+ });
738
+
739
+ it("server.ts registers the route", () => {
740
+ const server = fs.readFileSync(
741
+ path.join(__dirname, "..", "..", "..", "server.ts"),
742
+ "utf8",
743
+ );
744
+ expect(server).toMatch(/await server\.register\(shownOfferingsRoute/);
745
+ });
746
+
747
+ // `tests/parity/` is the monorepo's own API ↔ MCP guard, which no scaffold
748
+ // carries (#9179), so this half runs only where that guard is. The gate is
749
+ // the guard's registry, never the README itself, so a deleted or renamed
750
+ // README fails here instead of skipping.
751
+ const PARITY_GUARD = path.join(REPO_ROOT, "tests", "parity", "api-parity");
752
+ const PARITY_README = path.join(REPO_ROOT, "tests", "parity", "README.md");
753
+ (fs.existsSync(PARITY_GUARD) ? it : it.skip)(
754
+ "the parity README records the route",
755
+ () => {
756
+ const readme = fs.readFileSync(PARITY_README, "utf8");
757
+ expect(readme).toMatch(
758
+ /^\| `GET \/v1\/content\/shown-offerings` +\| `apps\/api\/src\/routes\/content\/shown-offerings\.ts` +\|/m,
759
+ );
760
+ },
761
+ );
762
+ });