create-ailk 0.24.0 → 0.25.1

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 (599) hide show
  1. package/README.md +42 -20
  2. package/component-catalog.json +3 -3
  3. package/component-catalog.md +3 -3
  4. package/dist/add-module.js +4 -2
  5. package/dist/add-route.d.ts +12 -1
  6. package/dist/add-route.js +31 -3
  7. package/dist/cli.js +12 -7
  8. package/dist/derive-architecture.d.ts +3 -4
  9. package/dist/derive-architecture.js +65 -29
  10. package/dist/lib/design-checks.d.ts +9 -4
  11. package/dist/lib/design-checks.js +13 -7
  12. package/dist/lib/extract-module-bundle.js +29 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +1 -1
  14. package/dist/lib/fetch-template-snapshot.js +7 -3
  15. package/dist/lib/import-graph.d.ts +1 -1
  16. package/dist/lib/import-graph.js +4 -4
  17. package/dist/lib/module-license-gate.d.ts +12 -1
  18. package/dist/lib/module-license-gate.js +34 -12
  19. package/dist/module-architecture.d.ts +20 -3
  20. package/dist/module-architecture.js +117 -550
  21. package/dist/paid-sections.d.ts +25 -1
  22. package/dist/paid-sections.js +88 -3
  23. package/dist/surfaces.js +10 -2
  24. package/dist/template-record.d.ts +14 -4
  25. package/dist/template-record.js +23 -5
  26. package/dist/topology.d.ts +16 -3
  27. package/dist/topology.js +31 -19
  28. package/dist/update.d.ts +10 -1
  29. package/dist/update.js +540 -16
  30. package/package.json +3 -3
  31. package/release-scope.json +1 -1
  32. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  33. package/templates/.claude/hooks/template-guard.sh +137 -0
  34. package/templates/.claude/rules/extending.md +106 -0
  35. package/templates/.claude/rules/loop-contract.md +2 -2
  36. package/templates/.claude/settings.json +11 -0
  37. package/templates/.env.example +5 -5
  38. package/templates/.github/workflows/ci.yml +7 -4
  39. package/templates/CLAUDE.md +11 -1
  40. package/templates/CONVENTIONS.md +8 -10
  41. package/templates/README.md +14 -13
  42. package/templates/apps/api/.env.example +2 -2
  43. package/templates/apps/api/CLAUDE.md +102 -76
  44. package/templates/apps/api/jest.config.cjs +15 -5
  45. package/templates/apps/api/package.json +16 -20
  46. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
  47. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
  48. package/templates/apps/api/src/index.ts +1 -1
  49. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
  50. package/templates/apps/api/src/modules/commerce.ts +10 -0
  51. package/templates/apps/api/src/modules/content-write.ts +10 -0
  52. package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
  53. package/templates/apps/api/src/modules/scheduling.ts +10 -0
  54. package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
  55. package/templates/apps/api/src/modules/workspace.ts +10 -0
  56. package/templates/apps/api/src/server.ts +117 -1882
  57. package/templates/apps/api/vercel.json +2 -2
  58. package/templates/apps/mcp/CLAUDE.md +67 -20
  59. package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
  60. package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
  61. package/templates/apps/mcp/jest.config.cjs +6 -1
  62. package/templates/apps/mcp/package.json +5 -13
  63. package/templates/apps/mcp/src/app.ts +8 -7
  64. package/templates/apps/mcp/src/index.ts +6 -34
  65. package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
  66. package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
  67. package/templates/apps/mcp/src/site.ts +12 -0
  68. package/templates/apps/web/.env.example +1 -1
  69. package/templates/apps/web/CLAUDE.md +10 -3
  70. package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
  71. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
  72. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
  73. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
  74. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
  75. package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
  76. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
  77. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
  78. package/templates/apps/web/app/api/revalidate/route.ts +3 -3
  79. package/templates/apps/web/auth.ts +2 -2
  80. package/templates/apps/web/jest.config.cjs +1 -0
  81. package/templates/apps/web/lib/auth-email.ts +1 -1
  82. package/templates/apps/web/lib/relay.ts +2 -2
  83. package/templates/apps/web/lib/release-scope.ts +47 -0
  84. package/templates/apps/web/lib/server-api.ts +1 -1
  85. package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
  86. package/templates/customizations.yaml +37 -0
  87. package/templates/database/prisma.config.ts +10 -14
  88. package/templates/database/seed-test.ts +2 -2
  89. package/templates/database/seed.ts +5 -5
  90. package/templates/package.json +12 -6
  91. package/templates/pnpm-workspace.yaml +0 -1
  92. package/templates/project.yaml +16 -8
  93. package/templates/tools/jest/reporters.cjs +65 -0
  94. package/templates/tools/lint-architecture.ts +553 -7
  95. package/templates/tsconfig.json +1 -2
  96. package/versions.json +48 -35
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
  98. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
  99. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
  100. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
  101. package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
  102. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
  103. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
  104. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
  105. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
  106. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
  107. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
  108. package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
  109. package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
  110. package/templates/apps/api/src/bin/spokes.ts +0 -198
  111. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
  112. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
  113. package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
  114. package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
  115. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
  116. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
  117. package/templates/apps/api/src/config/index.ts +0 -596
  118. package/templates/apps/api/src/config/modules.ts +0 -83
  119. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
  120. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
  121. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
  122. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
  123. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
  124. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
  125. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
  126. package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
  127. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
  128. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
  129. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
  130. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
  131. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
  132. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
  133. package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
  134. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
  135. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
  136. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
  137. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
  138. package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
  139. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
  140. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
  141. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
  142. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
  143. package/templates/apps/api/src/lib/agent-store.ts +0 -613
  144. package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
  145. package/templates/apps/api/src/lib/consent-store.ts +0 -363
  146. package/templates/apps/api/src/lib/content-adapter.ts +0 -112
  147. package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
  148. package/templates/apps/api/src/lib/error-serializer.ts +0 -164
  149. package/templates/apps/api/src/lib/errors.ts +0 -95
  150. package/templates/apps/api/src/lib/file-fallback.ts +0 -87
  151. package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
  152. package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
  153. package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
  154. package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
  155. package/templates/apps/api/src/lib/license-key-store.ts +0 -45
  156. package/templates/apps/api/src/lib/locales.ts +0 -201
  157. package/templates/apps/api/src/lib/outbound-http.ts +0 -127
  158. package/templates/apps/api/src/lib/prisma.ts +0 -21
  159. package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
  160. package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
  161. package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
  162. package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
  163. package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
  164. package/templates/apps/api/src/lib/site.ts +0 -257
  165. package/templates/apps/api/src/lib/slice-load.ts +0 -90
  166. package/templates/apps/api/src/lib/spoke-store.ts +0 -180
  167. package/templates/apps/api/src/lib/stripe.ts +0 -321
  168. package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
  169. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
  170. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
  171. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
  172. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
  173. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
  174. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
  175. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
  176. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
  177. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
  178. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
  179. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
  180. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
  181. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
  182. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
  183. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
  184. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
  185. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
  186. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
  187. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
  188. package/templates/apps/api/src/middleware/auth.ts +0 -864
  189. package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
  190. package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
  191. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
  192. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
  193. package/templates/apps/api/src/openapi/index.ts +0 -18
  194. package/templates/apps/api/src/openapi/spec.ts +0 -2148
  195. package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
  196. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
  197. package/templates/apps/api/src/routes/aeo/index.ts +0 -107
  198. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
  199. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
  200. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
  201. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
  202. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
  203. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
  204. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
  205. package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
  206. package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
  207. package/templates/apps/api/src/routes/auth/user.ts +0 -305
  208. package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
  209. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
  210. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
  211. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
  212. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
  213. package/templates/apps/api/src/routes/checkout/index.ts +0 -34
  214. package/templates/apps/api/src/routes/checkout/session.ts +0 -171
  215. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
  216. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
  217. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
  218. package/templates/apps/api/src/routes/consent/account.ts +0 -268
  219. package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
  220. package/templates/apps/api/src/routes/consent/index.ts +0 -26
  221. package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
  222. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
  223. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
  224. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
  225. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
  226. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
  227. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
  228. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
  229. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
  230. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
  231. package/templates/apps/api/src/routes/content/get-page.ts +0 -183
  232. package/templates/apps/api/src/routes/content/index.ts +0 -49
  233. package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
  234. package/templates/apps/api/src/routes/content/list-types.ts +0 -19
  235. package/templates/apps/api/src/routes/content/offerings.ts +0 -184
  236. package/templates/apps/api/src/routes/content/pricing.ts +0 -176
  237. package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
  238. package/templates/apps/api/src/routes/content/search.ts +0 -100
  239. package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
  240. package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
  241. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
  242. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
  243. package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
  244. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
  245. package/templates/apps/api/src/routes/leads/index.ts +0 -639
  246. package/templates/apps/api/src/routes/leads/list.ts +0 -104
  247. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
  248. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
  249. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
  250. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
  251. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
  252. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
  253. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
  254. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
  255. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
  256. package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
  257. package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
  258. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
  259. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
  260. package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
  261. package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
  262. package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
  263. package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
  264. package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
  265. package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
  266. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
  267. package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
  268. package/templates/apps/api/src/routes/requests/index.ts +0 -54
  269. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
  270. package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
  271. package/templates/apps/api/src/routes/webhooks/README.md +0 -398
  272. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
  273. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
  274. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
  275. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
  276. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
  277. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
  278. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
  279. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
  280. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
  281. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
  282. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
  283. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
  284. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
  285. package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
  286. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
  287. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
  288. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
  289. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
  290. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
  291. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
  292. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
  293. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
  294. package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
  295. package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
  296. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
  297. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
  298. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
  299. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
  300. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
  301. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
  302. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
  303. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
  304. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
  305. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
  306. package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
  307. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
  308. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
  309. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
  310. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
  311. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
  312. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
  313. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
  314. package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
  315. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
  316. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
  317. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
  318. package/templates/apps/api/src/services/abandon-email.ts +0 -336
  319. package/templates/apps/api/src/services/aeo-score.ts +0 -325
  320. package/templates/apps/api/src/services/answer-distributions.ts +0 -138
  321. package/templates/apps/api/src/services/audit-log.ts +0 -961
  322. package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
  323. package/templates/apps/api/src/services/consent.ts +0 -834
  324. package/templates/apps/api/src/services/default-tenant.ts +0 -416
  325. package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
  326. package/templates/apps/api/src/services/dunning-policy.ts +0 -294
  327. package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
  328. package/templates/apps/api/src/services/entitlements.ts +0 -338
  329. package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
  330. package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
  331. package/templates/apps/api/src/services/lead-promotion.ts +0 -160
  332. package/templates/apps/api/src/services/lead-routing.ts +0 -1372
  333. package/templates/apps/api/src/services/leads-inbox.ts +0 -312
  334. package/templates/apps/api/src/services/listing-config.ts +0 -178
  335. package/templates/apps/api/src/services/magnet-completion.ts +0 -242
  336. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
  337. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
  338. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
  339. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
  340. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
  341. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
  342. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
  343. package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
  344. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
  345. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
  346. package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
  347. package/templates/apps/api/src/services/offerings/index.ts +0 -614
  348. package/templates/apps/api/src/services/offerings/source.ts +0 -140
  349. package/templates/apps/api/src/services/offerings/store.ts +0 -150
  350. package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
  351. package/templates/apps/api/src/services/person.ts +0 -1211
  352. package/templates/apps/api/src/services/plan-offerings.ts +0 -125
  353. package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
  354. package/templates/apps/api/src/services/project-listings.ts +0 -2085
  355. package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
  356. package/templates/apps/api/src/services/request-confirm.ts +0 -698
  357. package/templates/apps/api/src/services/revalidation.ts +0 -235
  358. package/templates/apps/api/src/services/slack-notify.ts +0 -291
  359. package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
  360. package/templates/apps/api/src/services/usage-credits.ts +0 -340
  361. package/templates/apps/api/src/services/verified-email.ts +0 -71
  362. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
  363. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
  364. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
  365. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
  366. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
  367. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
  368. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
  369. package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
  370. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
  371. package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
  372. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
  373. package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
  374. package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
  375. package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
  376. package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
  377. package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
  378. package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
  379. package/templates/apps/mcp/__tests__/index.test.ts +0 -195
  380. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
  381. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
  382. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
  383. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
  384. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
  385. package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
  386. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
  387. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
  388. package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
  389. package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
  390. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
  391. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
  392. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
  393. package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
  394. package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
  395. package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
  396. package/templates/apps/mcp/__tests__/server.test.ts +0 -388
  397. package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
  398. package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
  399. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
  400. package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
  401. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
  402. package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
  403. package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
  404. package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
  405. package/templates/apps/mcp/src/abuse/index.ts +0 -349
  406. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
  407. package/templates/apps/mcp/src/boot.ts +0 -167
  408. package/templates/apps/mcp/src/config/modules.ts +0 -43
  409. package/templates/apps/mcp/src/http-transport.ts +0 -564
  410. package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
  411. package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
  412. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
  413. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
  414. package/templates/apps/mcp/src/lib/errors.ts +0 -61
  415. package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
  416. package/templates/apps/mcp/src/lib/locales.ts +0 -171
  417. package/templates/apps/mcp/src/lib/prisma.ts +0 -39
  418. package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
  419. package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
  420. package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
  421. package/templates/apps/mcp/src/lib/site-config.ts +0 -53
  422. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
  423. package/templates/apps/mcp/src/request-context.ts +0 -52
  424. package/templates/apps/mcp/src/server.ts +0 -343
  425. package/templates/apps/mcp/src/serverless-app.ts +0 -88
  426. package/templates/apps/mcp/src/tools/audit.ts +0 -127
  427. package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
  428. package/templates/apps/mcp/src/tools/create_project.ts +0 -134
  429. package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
  430. package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
  431. package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
  432. package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
  433. package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
  434. package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
  435. package/templates/apps/mcp/src/tools/index.ts +0 -138
  436. package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
  437. package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
  438. package/templates/apps/mcp/src/tools/proposals.ts +0 -27
  439. package/templates/apps/mcp/src/tools/purchase.ts +0 -205
  440. package/templates/apps/mcp/src/tools/read_content.ts +0 -81
  441. package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
  442. package/templates/apps/mcp/src/tools/search_content.ts +0 -63
  443. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
  444. package/templates/apps/mcp/src/workspace-server.ts +0 -224
  445. package/templates/database/CHANGELOG.md +0 -981
  446. package/templates/database/LICENSE +0 -202
  447. package/templates/database/NOTICE +0 -2
  448. package/templates/database/README.md +0 -243
  449. package/templates/database/__tests__/agents.db.test.ts +0 -320
  450. package/templates/database/__tests__/appointment.db.test.ts +0 -106
  451. package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
  452. package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
  453. package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
  454. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
  455. package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
  456. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
  457. package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
  458. package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
  459. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
  460. package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
  461. package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
  462. package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
  463. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
  464. package/templates/database/__tests__/flows-presets.test.ts +0 -173
  465. package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
  466. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
  467. package/templates/database/__tests__/member-area.db.test.ts +0 -257
  468. package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
  469. package/templates/database/__tests__/member-contract.db.test.ts +0 -413
  470. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
  471. package/templates/database/__tests__/offerings-migration.test.ts +0 -197
  472. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
  473. package/templates/database/__tests__/offerings.db.test.ts +0 -303
  474. package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
  475. package/templates/database/__tests__/person-contact.db.test.ts +0 -563
  476. package/templates/database/__tests__/person-notes.db.test.ts +0 -249
  477. package/templates/database/__tests__/person.db.test.ts +0 -586
  478. package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
  479. package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
  480. package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
  481. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
  482. package/templates/database/__tests__/seed.test.ts +0 -272
  483. package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
  484. package/templates/database/__tests__/tenancy.test.ts +0 -141
  485. package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
  486. package/templates/database/__tests__/user-cascade.test.ts +0 -140
  487. package/templates/database/_gitignore +0 -8
  488. package/templates/database/auth/.gitkeep +0 -0
  489. package/templates/database/auth/schema.prisma +0 -641
  490. package/templates/database/config/.gitkeep +0 -0
  491. package/templates/database/config/schema.prisma +0 -441
  492. package/templates/database/content/.gitkeep +0 -0
  493. package/templates/database/content/schema.prisma +0 -339
  494. package/templates/database/eslint.config.mjs +0 -69
  495. package/templates/database/flows/listing-diagnostic.ts +0 -182
  496. package/templates/database/flows/presets.ts +0 -315
  497. package/templates/database/inbox/.gitkeep +0 -0
  498. package/templates/database/inbox/README.md +0 -3
  499. package/templates/database/inbox/__tests__/schema.test.ts +0 -44
  500. package/templates/database/inbox/schema.prisma +0 -1447
  501. package/templates/database/jest.config.cjs +0 -36
  502. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
  503. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
  504. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
  505. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
  506. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
  507. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
  508. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
  509. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
  510. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
  511. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
  512. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
  513. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
  514. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
  515. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
  516. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
  517. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
  518. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
  519. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
  520. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
  521. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
  522. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
  523. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
  524. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
  525. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
  526. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
  527. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
  528. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
  529. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
  530. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
  531. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
  532. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
  533. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
  534. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
  535. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
  536. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
  537. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
  538. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
  539. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
  540. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
  541. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
  542. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
  543. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
  544. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
  545. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
  546. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
  547. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
  548. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
  549. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
  550. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
  551. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
  552. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
  553. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
  554. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
  555. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
  556. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
  557. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
  558. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
  559. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
  560. package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
  561. package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
  562. package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
  563. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
  564. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
  565. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
  566. package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
  567. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
  568. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
  569. package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
  570. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
  571. package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
  572. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
  573. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
  574. package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
  575. package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
  576. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
  577. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
  578. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
  579. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
  580. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
  581. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
  582. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
  583. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
  584. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
  585. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
  586. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
  587. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
  588. package/templates/database/migrations/migration_lock.toml +0 -3
  589. package/templates/database/ops/.gitkeep +0 -0
  590. package/templates/database/ops/schema.prisma +0 -43
  591. package/templates/database/package.json +0 -89
  592. package/templates/database/schema.prisma +0 -71
  593. package/templates/database/scripts/db-generate-locked.sh +0 -80
  594. package/templates/database/scripts/person-backfill.sql +0 -132
  595. package/templates/database/tenancy.ts +0 -183
  596. package/templates/database/tsconfig.build.json +0 -16
  597. package/templates/database/tsconfig.flows.json +0 -15
  598. package/templates/database/tsconfig.json +0 -16
  599. package/templates/database/tsconfig.tenancy.json +0 -14
@@ -1,872 +0,0 @@
1
- /**
2
- * @file resume-email.test.ts
3
- *
4
- * THE ACCEPTANCE GATE for the resume email's SEND (#5406) — the fire-and-forget
5
- * side effect `POST /v1/project-listings` gained.
6
- *
7
- * Four properties fail quietly if they regress, which is why each is here:
8
- * - the send is OPT-IN, so a site already mailing its own resume link (the
9
- * reason this issue exists) does not start sending a second one;
10
- * - the 201 is never delayed or failed by Resend, because the draft has
11
- * already committed and the founder must get their token back;
12
- * - nothing logs the founder's address, their name, or the resume token —
13
- * the one credential on this anonymous draft, which also rides the LINK;
14
- * - the site's config is read PER SEND, so editing the row changes the next
15
- * email with no rebuild and no module reload.
16
- *
17
- * Real route, real service, mocked `lib/prisma.js` over a small in-memory row
18
- * store (the `drafts.test.ts` discipline). `@working-theory/email` is mocked
19
- * because it is an external package boundary whose own contract is tested in
20
- * `packages/email/__tests__/send-listing-resume-email.test.ts` — and because
21
- * this container has no egress, so no email could be sent regardless.
22
- * `@working-theory/license` is mocked for the reason
23
- * docs/lessons/project-listings/02 records: the route barrel drags in
24
- * `routeLead`'s import chain whether or not a test exercises it.
25
- */
26
- import Fastify from "fastify";
27
- import type { FastifyInstance } from "fastify";
28
-
29
- // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock
30
- jest.mock("../../../lib/prisma.js");
31
-
32
- const mockSendResumeEmail = jest.fn();
33
- jest.mock("@working-theory/email", () => ({
34
- sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
35
- sendListingResumeEmail: (...args: unknown[]) => mockSendResumeEmail(...args),
36
- }));
37
-
38
- jest.mock("@working-theory/license", () => ({
39
- checkLicense: jest.fn().mockResolvedValue({
40
- tier: "free",
41
- plan: null,
42
- features: [],
43
- expiresAt: null,
44
- revoked: false,
45
- }),
46
- }));
47
-
48
- const mockLoggerInfo = jest.fn();
49
- const mockLoggerWarn = jest.fn();
50
- const mockLoggerError = jest.fn();
51
- jest.mock("@working-theory/observability", () => ({
52
- logger: {
53
- info: (...args: unknown[]) => mockLoggerInfo(...args),
54
- warn: (...args: unknown[]) => mockLoggerWarn(...args),
55
- error: (...args: unknown[]) => mockLoggerError(...args),
56
- debug: jest.fn(),
57
- },
58
- }));
59
-
60
- import {
61
- mockNoWorkspace,
62
- mockWorkspaceExists,
63
- } from "../../../lib/__mocks__/prisma.js";
64
- import { prisma } from "../../../lib/prisma.js";
65
- import { startProjectListingRoute } from "../start.js";
66
-
67
- // ─── Fixtures ───────────────────────────────────────────────────────────────
68
-
69
- const SITE_ID = "site-1";
70
- const SITE_KEY = "site-1-public-key";
71
-
72
- const FOUNDER_EMAIL = "ada@example.com";
73
- const FOUNDER_NAME = "Ada Founder";
74
-
75
- const START_PAYLOAD = {
76
- siteKey: SITE_KEY,
77
- founderName: FOUNDER_NAME,
78
- founderEmail: FOUNDER_EMAIL,
79
- consent: true,
80
- };
81
-
82
- const BASE_URL = "https://samhenry.org/projects/apply/start";
83
-
84
- /** The minimum a site writes to turn the email on. */
85
- const RESUME_EMAIL_ON = { fromAddress: "hello@samhenry.org", baseUrl: BASE_URL };
86
-
87
- // ─── The in-memory row store ────────────────────────────────────────────────
88
-
89
- type Row = Record<string, unknown>;
90
-
91
- let rows: Row[];
92
- let nextId: number;
93
- /** What `prisma.siteListingConfig.findUnique({ where: { siteId } })` answers. */
94
- let storedConfig: Record<string, unknown> | null;
95
- /**
96
- * The siteId `SITE_KEY` resolves to. `SITE_ID` for every test that does not
97
- * care; the per-site budget's tests each claim their OWN site, because the
98
- * cap's once-per-window log is remembered PER SITE in module state and two
99
- * tests sharing a site would share that memory (#5414).
100
- */
101
- let resolvedSiteId: string;
102
-
103
- function matches(row: Row, where: Record<string, unknown>): boolean {
104
- return Object.entries(where).every(([key, value]) => {
105
- // The per-recipient cap's window clause.
106
- if (value !== null && typeof value === "object" && !(value instanceof Date)) {
107
- const clause = value as Record<string, unknown>;
108
- if ("gt" in clause) return (row[key] as Date) > (clause["gt"] as Date);
109
- }
110
- return row[key] === value;
111
- });
112
- }
113
-
114
- function installStore(): void {
115
- // #6335 — the shared prisma mock no longer resolves ANY requested
116
- // workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
117
- // by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
118
- // services/project-listings.ts), so this suite states the ONE site it is
119
- // built around. Keyed on the requested id — nothing else resolves.
120
- mockWorkspaceExists(prisma, SITE_ID);
121
-
122
- rows = [];
123
- nextId = 1;
124
- storedConfig = null;
125
- resolvedSiteId = SITE_ID;
126
-
127
- // One delegate, two lookups: `where.publicKey` is the site-key resolution,
128
- // `where.siteId` the config read.
129
- (prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
130
- ({ where }: { where: { siteId?: string; publicKey?: string } }) => {
131
- if (where.publicKey !== undefined) {
132
- return Promise.resolve(where.publicKey === SITE_KEY ? { siteId: resolvedSiteId } : null);
133
- }
134
- return Promise.resolve(storedConfig === null ? null : { data: storedConfig });
135
- },
136
- );
137
-
138
- (prisma.$transaction as jest.Mock).mockImplementation(
139
- async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma),
140
- );
141
-
142
- (prisma.projectListing.create as jest.Mock).mockImplementation(({ data }: { data: Row }) => {
143
- const row: Row = {
144
- id: `prjl_00000000-0000-7000-8000-00000000000${nextId++}`,
145
- name: null,
146
- leadId: null,
147
- // Through `Date.now()` rather than a bare `new Date()` so a test that
148
- // moves the clock moves the rows with it (the once-per-window log).
149
- createdAt: new Date(Date.now()),
150
- updatedAt: new Date(Date.now()),
151
- ...data,
152
- };
153
- rows.push(row);
154
- return Promise.resolve(row);
155
- });
156
-
157
- (prisma.projectListing.findFirst as jest.Mock).mockImplementation(
158
- ({ where }: { where: Record<string, unknown> }) =>
159
- Promise.resolve(rows.find((row) => matches(row, where)) ?? null),
160
- );
161
-
162
- (prisma.projectListing.findMany as jest.Mock).mockImplementation(() => Promise.resolve([]));
163
-
164
- // Both send caps' read: the per-recipient bound (#5411 security review,
165
- // finding 1) and the per-site budget (#5414) are the same delegate with
166
- // different `where` clauses.
167
- (prisma.projectListing.count as jest.Mock).mockImplementation(
168
- ({ where }: { where: Record<string, unknown> }) =>
169
- Promise.resolve(rows.filter((row) => matches(row, where)).length),
170
- );
171
-
172
- (prisma.consentGrant.createMany as jest.Mock).mockImplementation(({ data }: { data: Row[] }) =>
173
- Promise.resolve({ count: data.length }),
174
- );
175
- }
176
-
177
- // ─── Helpers ────────────────────────────────────────────────────────────────
178
-
179
- async function buildApp(): Promise<FastifyInstance> {
180
- const app = Fastify();
181
- await app.register(startProjectListingRoute);
182
- await app.ready();
183
- return app;
184
- }
185
-
186
- /**
187
- * Let the fire-and-forget chain run. The send does several awaited reads before
188
- * it reaches the email — the row, the config, and one count per bound — so a
189
- * single microtask tick is not enough; `setImmediate` fires only after the
190
- * microtask queue has fully drained.
191
- */
192
- const settle = () => new Promise((resolve) => setImmediate(resolve));
193
-
194
- async function startDraft(app: FastifyInstance): Promise<{ id: string; resumeToken: string }> {
195
- const res = await app.inject({
196
- method: "POST",
197
- url: "/v1/project-listings",
198
- payload: START_PAYLOAD,
199
- });
200
- expect(res.statusCode).toBe(201);
201
- return (res.json() as { ok: true; data: { id: string; resumeToken: string } }).data;
202
- }
203
-
204
- /** The single `sendListingResumeEmail` argument of the most recent call. */
205
- function emailed(index = 0): {
206
- to: string;
207
- resumeLink: string;
208
- settings: Record<string, string>;
209
- variables: Record<string, string>;
210
- } {
211
- return mockSendResumeEmail.mock.calls[index]![0];
212
- }
213
-
214
- describe("the resume email's send (#5406)", () => {
215
- let app: FastifyInstance;
216
-
217
- beforeEach(async () => {
218
- jest.clearAllMocks();
219
- mockSendResumeEmail.mockResolvedValue({ ok: true, messageId: "msg-resume" });
220
- installStore();
221
- app = await buildApp();
222
- });
223
-
224
- afterEach(async () => {
225
- await app.close();
226
- });
227
-
228
- // ── Opt-in ───────────────────────────────────────────────────────────────
229
-
230
- it("sends NOTHING when the site has no SiteListingConfig row at all", async () => {
231
- await startDraft(app);
232
- await settle();
233
- expect(mockSendResumeEmail).not.toHaveBeenCalled();
234
- });
235
-
236
- it("sends NOTHING when the config carries no resumeEmail block", async () => {
237
- storedConfig = { maxWindowDays: 30 };
238
- await startDraft(app);
239
- await settle();
240
- expect(mockSendResumeEmail).not.toHaveBeenCalled();
241
- });
242
-
243
- it("sends NOTHING when resumeEmail carries no fromAddress", async () => {
244
- // The opt-in switch. A site with wording but no verified sender must not
245
- // start mailing — and a site already sending its own must not get two.
246
- storedConfig = { resumeEmail: { subject: "Come back", baseUrl: BASE_URL } };
247
- await startDraft(app);
248
- await settle();
249
- expect(mockSendResumeEmail).not.toHaveBeenCalled();
250
- expect(mockLoggerInfo).toHaveBeenCalledWith(
251
- expect.objectContaining({ reason: "no_from_address" }),
252
- "listing-resume-email-skipped",
253
- );
254
- });
255
-
256
- it("sends NOTHING when resumeEmail carries no baseUrl", async () => {
257
- // The platform does not know where a site mounted its application, so
258
- // there is no link to build and no default worth guessing.
259
- storedConfig = { resumeEmail: { fromAddress: "hello@samhenry.org" } };
260
- await startDraft(app);
261
- await settle();
262
- expect(mockSendResumeEmail).not.toHaveBeenCalled();
263
- expect(mockLoggerInfo).toHaveBeenCalledWith(
264
- expect.objectContaining({ reason: "no_base_url" }),
265
- "listing-resume-email-skipped",
266
- );
267
- });
268
-
269
- // ── The send ─────────────────────────────────────────────────────────────
270
-
271
- it("sends to the founder with the site's own settings once configured", async () => {
272
- storedConfig = {
273
- siteName: "Sam Henry",
274
- resumeEmail: {
275
- ...RESUME_EMAIL_ON,
276
- senderName: "Sam Henry",
277
- replyTo: "sam@samhenry.org",
278
- subject: "Verify to continue, {firstName}",
279
- outro: "Reply if you get stuck.",
280
- },
281
- };
282
- await startDraft(app);
283
- await settle();
284
-
285
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(1);
286
- expect(emailed().to).toBe(FOUNDER_EMAIL);
287
- expect(emailed().settings).toEqual({
288
- fromAddress: "hello@samhenry.org",
289
- senderName: "Sam Henry",
290
- replyTo: "sam@samhenry.org",
291
- subject: "Verify to continue, {firstName}",
292
- outro: "Reply if you get stuck.",
293
- });
294
- // `baseUrl` is the API's to consume, not the email package's.
295
- expect(emailed().settings).not.toHaveProperty("baseUrl");
296
- });
297
-
298
- it("builds the link as baseUrl + ?draft=<id>&token=<resumeToken>&verify=<nonce>", async () => {
299
- storedConfig = { resumeEmail: RESUME_EMAIL_ON };
300
- const { id, resumeToken } = await startDraft(app);
301
- await settle();
302
-
303
- // The exact shape `ListingApplicationFlow`'s `resume` prop reads.
304
- const url = new URL(emailed().resumeLink);
305
- expect(`${url.origin}${url.pathname}`).toBe(BASE_URL);
306
- expect(url.searchParams.get("draft")).toBe(id);
307
- expect(url.searchParams.get("token")).toBe(resumeToken);
308
- // #5407 — and the verification nonce, which is what makes
309
- // `email_verified_at` mean something the resume token could not prove.
310
- expect(url.searchParams.get("verify")).toEqual(expect.any(String));
311
- });
312
-
313
- // #5407 — this message is the ONLY carrier of the verification nonce, and
314
- // the property is what the whole fix rests on: a caller who has everything
315
- // `start` returned still cannot verify.
316
- it("carries a nonce that appears NOWHERE in `start`'s own response", async () => {
317
- storedConfig = { resumeEmail: RESUME_EMAIL_ON };
318
- const res = await app.inject({
319
- method: "POST",
320
- url: "/v1/project-listings",
321
- payload: START_PAYLOAD,
322
- });
323
- await settle();
324
-
325
- const nonce = new URL(emailed().resumeLink).searchParams.get("verify")!;
326
- expect(nonce).toEqual(expect.any(String));
327
- expect(res.body).not.toContain(nonce);
328
- // Nor is it any of the values the response DOES carry.
329
- const data = (res.json() as { data: Record<string, string> }).data;
330
- expect(Object.values(data)).not.toContain(nonce);
331
- });
332
-
333
- it("gives each draft its OWN nonce", async () => {
334
- storedConfig = { resumeEmail: RESUME_EMAIL_ON };
335
- await startDraft(app);
336
- await settle();
337
- await startDraft(app);
338
- await settle();
339
-
340
- const first = new URL(emailed(0).resumeLink).searchParams.get("verify");
341
- const second = new URL(emailed(1).resumeLink).searchParams.get("verify");
342
- expect(first).not.toBe(second);
343
- });
344
-
345
- it("appends to a baseUrl that already carries a query string", async () => {
346
- storedConfig = { resumeEmail: { ...RESUME_EMAIL_ON, baseUrl: `${BASE_URL}?locale=en` } };
347
- const { id } = await startDraft(app);
348
- await settle();
349
-
350
- const url = new URL(emailed().resumeLink);
351
- expect(url.searchParams.get("locale")).toBe("en");
352
- expect(url.searchParams.get("draft")).toBe(id);
353
- });
354
-
355
- it("passes {siteName} from the config, and leaves {firstName}/{projectName} unset", async () => {
356
- storedConfig = { siteName: "Sam Henry", resumeEmail: RESUME_EMAIL_ON };
357
- await startDraft(app);
358
- await settle();
359
-
360
- // `name` is null on a step-one draft, and THIS payload sends one display
361
- // line rather than the two name parts — `founderName` is deliberately not
362
- // split to guess a first name, so `{firstName}` stays unset. Both render
363
- // as empty text in the email package.
364
- expect(emailed().variables).toEqual({ siteName: "Sam Henry" });
365
- });
366
-
367
- // #5407 — the column the note above was waiting for. `{firstName}` is
368
- // populated from `founderFirstName` and only where the founder actually
369
- // gave one; nothing derives it from `founderName`.
370
- it("passes {firstName} when the founder gave one, and never guesses it", async () => {
371
- storedConfig = { siteName: "Sam Henry", resumeEmail: RESUME_EMAIL_ON };
372
- const res = await app.inject({
373
- method: "POST",
374
- url: "/v1/project-listings",
375
- payload: {
376
- siteKey: SITE_KEY,
377
- founderFirstName: "Ada",
378
- founderLastName: "Lovelace",
379
- founderEmail: FOUNDER_EMAIL,
380
- consent: true,
381
- },
382
- });
383
- expect(res.statusCode).toBe(201);
384
- await settle();
385
-
386
- expect(emailed().variables).toEqual({ siteName: "Sam Henry", firstName: "Ada" });
387
- });
388
-
389
- it("passes no {siteName} when the site has not named itself", async () => {
390
- storedConfig = { resumeEmail: RESUME_EMAIL_ON };
391
- await startDraft(app);
392
- await settle();
393
- expect(emailed().variables).toEqual({});
394
- });
395
-
396
- // ── The 201 is independent of the email ──────────────────────────────────
397
-
398
- it("answers 201 with the token even when Resend FAILS", async () => {
399
- storedConfig = { resumeEmail: RESUME_EMAIL_ON };
400
- mockSendResumeEmail.mockResolvedValue({
401
- ok: false,
402
- error: { kind: "send_failed", message: "Resend API error" },
403
- });
404
-
405
- const { id, resumeToken } = await startDraft(app);
406
- expect(id).toMatch(/^prjl_/);
407
- expect(resumeToken.length).toBeGreaterThan(0);
408
-
409
- await settle();
410
- // Logged as a KIND, never as the failure's own contents.
411
- expect(mockLoggerWarn).toHaveBeenCalledWith(
412
- expect.objectContaining({ errorKind: "send_failed" }),
413
- "listing resume email send failed",
414
- );
415
- });
416
-
417
- it("answers 201 even when the email THROWS", async () => {
418
- storedConfig = { resumeEmail: RESUME_EMAIL_ON };
419
- mockSendResumeEmail.mockRejectedValue(new Error("boom"));
420
-
421
- const { id } = await startDraft(app);
422
- expect(id).toMatch(/^prjl_/);
423
-
424
- await settle();
425
- expect(mockLoggerWarn).toHaveBeenCalledWith(
426
- expect.objectContaining({ errName: "Error" }),
427
- "listing resume email rejected unexpectedly",
428
- );
429
- });
430
-
431
- it("does NOT wait on the email — the 201 lands while the send is still pending", async () => {
432
- storedConfig = { resumeEmail: RESUME_EMAIL_ON };
433
- // A send that never settles. If the route awaited it, `inject` would hang
434
- // and this test would time out rather than assert.
435
- mockSendResumeEmail.mockReturnValue(new Promise(() => {}));
436
-
437
- const res = await app.inject({
438
- method: "POST",
439
- url: "/v1/project-listings",
440
- payload: START_PAYLOAD,
441
- });
442
- expect(res.statusCode).toBe(201);
443
- });
444
-
445
- // ── Per-recipient bound (PR #5411 security review, finding 1) ────────────
446
-
447
- describe("bounds outbound mail per recipient", () => {
448
- // `start` is ANONYMOUS and its IP limiter is evadable (`trustProxy`), so
449
- // without this a loop over one victim's address is a mail bomb sent from
450
- // the site's own verified domain. The cap is what makes an anonymous
451
- // outbound send affordable at all.
452
- beforeEach(() => {
453
- storedConfig = { resumeEmail: RESUME_EMAIL_ON };
454
- });
455
-
456
- it("sends the first three, then suppresses the fourth for the same address", async () => {
457
- for (let i = 0; i < 3; i++) {
458
- await startDraft(app);
459
- await settle();
460
- }
461
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(3);
462
-
463
- await startDraft(app);
464
- await settle();
465
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(3);
466
- expect(mockLoggerWarn).toHaveBeenCalledWith(
467
- expect.objectContaining({ reason: "recipient_rate_limited" }),
468
- "listing-resume-email-skipped",
469
- );
470
- });
471
-
472
- it("suppresses the EMAIL only — the draft is still created and the 201 still carries the token", async () => {
473
- // Lesson 14's rule: a quota on an unverified identifier is a lockout
474
- // weapon. What this one can deny is the email, never the application —
475
- // the founder in front of the form has their token from the 201.
476
- for (let i = 0; i < 4; i++) {
477
- await startDraft(app);
478
- await settle();
479
- }
480
- expect(rows).toHaveLength(4);
481
- const { id, resumeToken } = await startDraft(app);
482
- expect(id).toMatch(/^prjl_/);
483
- expect(resumeToken.length).toBeGreaterThan(0);
484
- });
485
-
486
- it("counts per (site, address) — a different address has its own budget", async () => {
487
- for (let i = 0; i < 4; i++) {
488
- await startDraft(app);
489
- await settle();
490
- }
491
- mockSendResumeEmail.mockClear();
492
-
493
- await app.inject({
494
- method: "POST",
495
- url: "/v1/project-listings",
496
- payload: { ...START_PAYLOAD, founderEmail: "grace@example.com" },
497
- });
498
- await settle();
499
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(1);
500
- });
501
-
502
- it("does not count drafts older than the window", async () => {
503
- // Rolling, so a lockout clears itself rather than being permanent.
504
- for (let i = 0; i < 4; i++) {
505
- await startDraft(app);
506
- await settle();
507
- }
508
- const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
509
- for (const row of rows) row["createdAt"] = twoHoursAgo;
510
- mockSendResumeEmail.mockClear();
511
-
512
- await startDraft(app);
513
- await settle();
514
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(1);
515
- });
516
- });
517
-
518
- // ── Per-site budget (#5414) ─────────────────────────────────────────────
519
-
520
- describe("bounds outbound mail per SITE", () => {
521
- // The residual #5406 stated rather than hid: the per-recipient bound stops
522
- // a loop aimed at ONE victim and does nothing about a loop over a thousand
523
- // strangers, each of whom receives one message from the site's own
524
- // verified sending domain. That is the pattern a mailbox provider reads as
525
- // a list blast, and the cost — a flagged domain, a suspendable Resend
526
- // account — lands on the site rather than on the caller.
527
- let capSite = 0;
528
- beforeEach(() => {
529
- resolvedSiteId = `site-cap-${++capSite}`;
530
- // #6335 — this describe files each test against a FRESH site, so the
531
- // existence statement follows `resolvedSiteId` rather than pinning
532
- // `SITE_ID`. Still keyed on the requested id: every other one
533
- // resolves to nothing.
534
- mockWorkspaceExists(prisma, resolvedSiteId);
535
- storedConfig = { resumeEmail: { ...RESUME_EMAIL_ON, maxSendsPerHour: 3 } };
536
- });
537
-
538
- /** The `site_rate_limited` warn lines emitted so far. */
539
- function capWarnings(): unknown[][] {
540
- return mockLoggerWarn.mock.calls.filter(
541
- ([fields]: [Record<string, unknown>]) => fields["reason"] === "site_rate_limited",
542
- );
543
- }
544
-
545
- /** Start a draft from an address nobody has used, so only the SITE budget binds. */
546
- async function startFromFreshAddress(n: number): Promise<void> {
547
- await app.inject({
548
- method: "POST",
549
- url: "/v1/project-listings",
550
- payload: { ...START_PAYLOAD, founderEmail: `founder-${n}@example.com` },
551
- });
552
- await settle();
553
- }
554
-
555
- it("sends to every stranger UNDER the cap", async () => {
556
- for (let i = 0; i < 3; i++) await startFromFreshAddress(i);
557
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(3);
558
- });
559
-
560
- it("suppresses the send once the site is AT its cap, however many addresses are used", async () => {
561
- for (let i = 0; i < 6; i++) await startFromFreshAddress(i);
562
- // Three sent, three suppressed — the per-recipient bound never fired,
563
- // since every address here is distinct and used once.
564
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(3);
565
- expect(mockLoggerWarn).toHaveBeenCalledWith(
566
- expect.objectContaining({ reason: "site_rate_limited" }),
567
- "listing-resume-email-skipped",
568
- );
569
- });
570
-
571
- it("keeps start's 201 and still creates the draft at the cap", async () => {
572
- for (let i = 0; i < 6; i++) await startFromFreshAddress(i);
573
- mockSendResumeEmail.mockClear();
574
-
575
- // The founder in front of the form loses the EMAIL and nothing else:
576
- // their draft exists and their resume token is in the response body.
577
- const res = await app.inject({
578
- method: "POST",
579
- url: "/v1/project-listings",
580
- payload: { ...START_PAYLOAD, founderEmail: "over-the-cap@example.com" },
581
- });
582
- expect(res.statusCode).toBe(201);
583
- const { id, resumeToken } = (
584
- res.json() as { data: { id: string; resumeToken: string } }
585
- ).data;
586
- expect(id).toMatch(/^prjl_/);
587
- expect(resumeToken.length).toBeGreaterThan(0);
588
- await settle();
589
- expect(mockSendResumeEmail).not.toHaveBeenCalled();
590
- expect(rows.some((row) => row["id"] === id)).toBe(true);
591
- });
592
-
593
- it("recovers when the window rolls past the earlier drafts", async () => {
594
- for (let i = 0; i < 6; i++) await startFromFreshAddress(i);
595
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(3);
596
-
597
- const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
598
- for (const row of rows) row["createdAt"] = twoHoursAgo;
599
- mockSendResumeEmail.mockClear();
600
-
601
- await startFromFreshAddress(99);
602
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(1);
603
- });
604
-
605
- it("counts each SITE independently — one site's flood does not silence another", async () => {
606
- // Tenant isolation, on the budget itself: the count is keyed on the
607
- // siteId read off the DRAFT ROW, so a site that has sent nothing sends.
608
- for (let i = 0; i < 6; i++) await startFromFreshAddress(i);
609
- mockSendResumeEmail.mockClear();
610
-
611
- const counts = (prisma.projectListing.count as jest.Mock).mock.calls.map(
612
- ([arg]: [{ where: Record<string, unknown> }]) => arg.where["siteId"],
613
- );
614
- expect(counts.length).toBeGreaterThan(0);
615
- for (const siteId of counts) expect(siteId).toBe(resolvedSiteId);
616
-
617
- // A second site, its own config row, its own empty budget.
618
- const OTHER_SITE = "site-2";
619
- // #6335 — named explicitly, beside this test's own site: the draft is
620
- // filed against OTHER_SITE, so its workspace must resolve too.
621
- mockWorkspaceExists(prisma, resolvedSiteId, OTHER_SITE);
622
- (prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
623
- ({ where }: { where: { siteId?: string; publicKey?: string } }) => {
624
- if (where.publicKey !== undefined) {
625
- return Promise.resolve({ siteId: OTHER_SITE });
626
- }
627
- return Promise.resolve({
628
- data: { resumeEmail: { ...RESUME_EMAIL_ON, maxSendsPerHour: 3 } },
629
- });
630
- },
631
- );
632
-
633
- await startFromFreshAddress(100);
634
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(1);
635
- expect(rows.at(-1)!["siteId"]).toBe(OTHER_SITE);
636
- });
637
-
638
- it("still applies the per-recipient bound under a generous site cap", async () => {
639
- // The two bounds are independent. A site with plenty of site-wide budget
640
- // must still not mail ONE address more than three times in an hour.
641
- storedConfig = { resumeEmail: { ...RESUME_EMAIL_ON, maxSendsPerHour: 1000 } };
642
- for (let i = 0; i < 5; i++) {
643
- await startDraft(app);
644
- await settle();
645
- }
646
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(3);
647
- expect(mockLoggerWarn).toHaveBeenCalledWith(
648
- expect.objectContaining({ reason: "recipient_rate_limited" }),
649
- "listing-resume-email-skipped",
650
- );
651
- });
652
-
653
- it("logs the site cap ONCE per window, with the siteId and the count only", async () => {
654
- for (let i = 0; i < 8; i++) await startFromFreshAddress(i);
655
-
656
- const capLogs = capWarnings();
657
- // Five sends were suppressed; a line per suppressed send would hand an
658
- // attacker a log-flooding primitive on top of everything else.
659
- expect(capLogs).toHaveLength(1);
660
- expect(capLogs[0]).toEqual([
661
- { siteId: resolvedSiteId, count: 4, reason: "site_rate_limited" },
662
- "listing-resume-email-skipped",
663
- ]);
664
- });
665
-
666
- it("logs AGAIN in the next window — the throttle is a window, not a latch", async () => {
667
- // The dedupe remembers per site in module state. If it never expired, a
668
- // site attacked on Monday would trip its cap silently for the rest of
669
- // the deployment's life, which is the failure mode a throttle is most
670
- // likely to have and least likely to be noticed having.
671
- const realNow = Date.now.bind(Date);
672
- try {
673
- for (let i = 0; i < 5; i++) await startFromFreshAddress(i);
674
- expect(capWarnings()).toHaveLength(1);
675
-
676
- // Two hours on. The earlier drafts fall out of the window with the
677
- // clock, so the budget refills and has to be spent again.
678
- jest.spyOn(Date, "now").mockImplementation(() => realNow() + 2 * 60 * 60 * 1000);
679
- for (let i = 10; i < 15; i++) await startFromFreshAddress(i);
680
- expect(capWarnings()).toHaveLength(2);
681
- } finally {
682
- jest.spyOn(Date, "now").mockRestore();
683
- }
684
- });
685
-
686
- it("logs no address, no name, no token and no draft id when the cap is hit", async () => {
687
- const tokens: string[] = [];
688
- for (let i = 0; i < 6; i++) {
689
- const res = await app.inject({
690
- method: "POST",
691
- url: "/v1/project-listings",
692
- payload: { ...START_PAYLOAD, founderEmail: `founder-${i}@example.com` },
693
- });
694
- tokens.push((res.json() as { data: { resumeToken: string } }).data.resumeToken);
695
- await settle();
696
- }
697
-
698
- const capLogs = JSON.stringify(capWarnings());
699
- expect(capLogs).not.toContain("founder-");
700
- expect(capLogs).not.toContain(FOUNDER_NAME);
701
- for (const token of tokens) expect(capLogs).not.toContain(token);
702
- for (const row of rows) expect(capLogs).not.toContain(row["id"] as string);
703
- expect(capLogs).not.toContain(BASE_URL);
704
- });
705
-
706
- it("falls back to the PLATFORM cap when the site wrote an invalid one", async () => {
707
- // `.catch(undefined)` on the member: a bad cap costs the CAP, never the
708
- // addressing beside it. Twenty is the platform default, so a site that
709
- // typed `0` sends its fourth email rather than none.
710
- storedConfig = {
711
- maxWindowDays: 7,
712
- resumeEmail: { ...RESUME_EMAIL_ON, maxSendsPerHour: 0 },
713
- };
714
- for (let i = 0; i < 4; i++) await startFromFreshAddress(i);
715
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(4);
716
- // And the operator is told their value is not in effect, by NAME only.
717
- expect(mockLoggerWarn).toHaveBeenCalledWith(
718
- { siteId: resolvedSiteId, field: "resumeEmail.maxSendsPerHour" },
719
- expect.stringContaining("DROPPED"),
720
- );
721
- });
722
-
723
- it("suppresses at the PLATFORM cap when the site configured none", async () => {
724
- storedConfig = { resumeEmail: RESUME_EMAIL_ON };
725
- for (let i = 0; i < 21; i++) await startFromFreshAddress(i);
726
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(20);
727
- });
728
- });
729
-
730
- // ── Tenant isolation ─────────────────────────────────────────────────────
731
-
732
- it("reads the config for the DRAFT'S OWN siteId, never one from the body", async () => {
733
- storedConfig = { resumeEmail: RESUME_EMAIL_ON };
734
- await startDraft(app);
735
- await settle();
736
-
737
- const configReads = (prisma.siteListingConfig.findUnique as jest.Mock).mock.calls.filter(
738
- ([arg]: [{ where: Record<string, unknown> }]) => arg.where["siteId"] !== undefined,
739
- );
740
- expect(configReads.length).toBeGreaterThan(0);
741
- for (const [arg] of configReads) {
742
- expect((arg as { where: Record<string, unknown> }).where["siteId"]).toBe(SITE_ID);
743
- }
744
- // And the row the send read its site off is the one just created.
745
- expect(rows[0]!["siteId"]).toBe(SITE_ID);
746
- });
747
-
748
- // ── Runtime configuration ────────────────────────────────────────────────
749
-
750
- it("sends TWICE with different config rows and no module reload", async () => {
751
- // The issue's whole premise: an operator edits SiteListingConfig and the
752
- // NEXT email changes. Nothing between the two starts re-imports a module,
753
- // restarts the app, or resets a cache — only the stored row moves.
754
- storedConfig = {
755
- siteName: "First Site",
756
- resumeEmail: { fromAddress: "one@a.example", baseUrl: "https://a.example/apply" },
757
- };
758
- await startDraft(app);
759
- await settle();
760
-
761
- storedConfig = {
762
- siteName: "Second Site",
763
- resumeEmail: {
764
- fromAddress: "two@b.example",
765
- baseUrl: "https://b.example/apply",
766
- subject: "A whole new subject",
767
- },
768
- };
769
- await startDraft(app);
770
- await settle();
771
-
772
- expect(mockSendResumeEmail).toHaveBeenCalledTimes(2);
773
- expect(emailed(0).settings.fromAddress).toBe("one@a.example");
774
- expect(emailed(0).variables.siteName).toBe("First Site");
775
- expect(emailed(0).settings.subject).toBeUndefined();
776
- expect(emailed(1).settings.fromAddress).toBe("two@b.example");
777
- expect(emailed(1).variables.siteName).toBe("Second Site");
778
- expect(emailed(1).settings.subject).toBe("A whole new subject");
779
- expect(emailed(1).resumeLink.startsWith("https://b.example/apply")).toBe(true);
780
- });
781
-
782
- // ── PII ──────────────────────────────────────────────────────────────────
783
-
784
- it("logs no founder email, no founder name, no resume token and no link", async () => {
785
- storedConfig = { siteName: "Sam Henry", resumeEmail: RESUME_EMAIL_ON };
786
- mockSendResumeEmail.mockResolvedValue({
787
- ok: false,
788
- error: { kind: "send_failed", message: "Resend API error" },
789
- });
790
-
791
- const { resumeToken } = await startDraft(app);
792
- await settle();
793
-
794
- const logged = JSON.stringify([
795
- ...mockLoggerInfo.mock.calls,
796
- ...mockLoggerWarn.mock.calls,
797
- ...mockLoggerError.mock.calls,
798
- ]);
799
- expect(logged).not.toContain(FOUNDER_EMAIL);
800
- expect(logged).not.toContain(FOUNDER_NAME);
801
- expect(logged).not.toContain(resumeToken);
802
- // The link CARRIES the token, so it is PII-equivalent on this surface.
803
- expect(logged).not.toContain(BASE_URL);
804
- // What IS logged: routing metadata only.
805
- expect(mockLoggerWarn).toHaveBeenCalledWith(
806
- { siteId: SITE_ID, listingId: rows[0]!["id"], errorKind: "send_failed" },
807
- "listing resume email send failed",
808
- );
809
- });
810
-
811
- // ── A dropped config block is observable (PR #5411 review, item 2) ───────
812
-
813
- it("warns when a malformed resumeEmail block is DROPPED, naming the field only", async () => {
814
- // `.catch(undefined)` keeps the site's window bounds when its email block
815
- // is malformed — correct behaviour, but silent. This is what makes it
816
- // findable: an operator who typos a key gets a line saying so.
817
- storedConfig = {
818
- maxWindowDays: 7,
819
- resumeEmail: { fromAddress: "hello@samhenry.org", subjct: "typo" },
820
- };
821
- await startDraft(app);
822
- await settle();
823
-
824
- expect(mockSendResumeEmail).not.toHaveBeenCalled();
825
- expect(mockLoggerWarn).toHaveBeenCalledWith(
826
- { siteId: SITE_ID, field: "resumeEmail" },
827
- expect.stringContaining("DROPPED"),
828
- );
829
- // Field name only — never the site's addresses or its copy.
830
- const logged = JSON.stringify(mockLoggerWarn.mock.calls);
831
- expect(logged).not.toContain("hello@samhenry.org");
832
- });
833
- });
834
-
835
- // ── #6335 — the explicit workspace setup above is load-bearing ────────────
836
- //
837
- // Every case in this file states that this suite's site has a workspace.
838
- // This one takes that away, and proves the statement was doing work rather
839
- // than decorating the setup: `startProjectListingDraft` resolves the site it
840
- // is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
841
- // and refuses BEFORE any row is written. With the shared prisma mock's old
842
- // permissive default in place this case could not be written at all — every
843
- // id resolved, so the fail-closed path was unreachable from a route test.
844
- describe("#6335 — start fails closed when the site names no workspace", () => {
845
- let app: FastifyInstance;
846
-
847
- beforeEach(async () => {
848
- jest.clearAllMocks();
849
- installStore();
850
- mockNoWorkspace(prisma);
851
- app = await buildApp();
852
- });
853
-
854
- afterEach(async () => {
855
- await app.close();
856
- });
857
-
858
- it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
859
- const res = await app.inject({
860
- method: "POST",
861
- url: "/v1/project-listings",
862
- payload: START_PAYLOAD,
863
- });
864
-
865
- expect(res.statusCode).toBe(503);
866
- expect((res.json() as { error: { code: string } }).error.code).toBe(
867
- "tenant_unresolved",
868
- );
869
- expect(prisma.projectListing.create).not.toHaveBeenCalled();
870
- expect(rows).toHaveLength(0);
871
- });
872
- });