create-ailk 0.1.1 → 0.3.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 (284) hide show
  1. package/component-catalog.md +210 -52
  2. package/dist/cli.js +0 -0
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +12 -3
  6. package/dist/lib/extract-module-bundle.js +236 -24
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/module-license-gate.d.ts +2 -0
  10. package/dist/lib/module-license-gate.js +1 -0
  11. package/dist/lib/paid-paths.d.ts +16 -0
  12. package/dist/lib/paid-paths.js +90 -0
  13. package/dist/module-architecture.d.ts +139 -23
  14. package/dist/module-architecture.js +668 -25
  15. package/dist/parse-args.d.ts +2 -2
  16. package/dist/parse-args.js +3 -1
  17. package/dist/programmatic.d.ts +2 -1
  18. package/dist/programmatic.js +15 -4
  19. package/dist/surfaces.d.ts +12 -36
  20. package/dist/surfaces.js +35 -5
  21. package/dist/sync-routes.js +31 -1
  22. package/package.json +14 -15
  23. package/templates/.claude/agents/web.md +2 -3
  24. package/templates/.claude/rules/architecture.md +5 -5
  25. package/templates/.claude/skills/README.md +2 -1
  26. package/templates/.env.example +21 -9
  27. package/templates/CONVENTIONS.md +6 -7
  28. package/templates/apps/api/.env.example +9 -0
  29. package/templates/apps/api/CLAUDE.md +35 -4
  30. package/templates/apps/api/package.json +0 -1
  31. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  32. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  33. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  34. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  35. package/templates/apps/api/src/config/index.ts +19 -0
  36. package/templates/apps/api/src/config/modules.ts +12 -14
  37. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  38. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  39. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  40. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  41. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  42. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  43. package/templates/apps/api/src/openapi/index.ts +9 -3
  44. package/templates/apps/api/src/openapi/spec.ts +505 -65
  45. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  46. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  47. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  48. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  49. package/templates/apps/api/src/routes/content/index.ts +38 -11
  50. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +28 -0
  51. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +144 -0
  52. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  53. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +478 -0
  54. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  55. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  56. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +717 -0
  57. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +35 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +484 -0
  59. package/templates/apps/api/src/routes/project-listings/index.ts +52 -10
  60. package/templates/apps/api/src/routes/project-listings/me.ts +86 -0
  61. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  62. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  63. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  64. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  65. package/templates/apps/api/src/routes/project-listings/start.ts +38 -0
  66. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  67. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  68. package/templates/apps/api/src/server.ts +471 -133
  69. package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
  70. package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
  71. package/templates/apps/api/src/services/consent.ts +236 -0
  72. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  73. package/templates/apps/api/src/services/listing-config.ts +58 -0
  74. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  75. package/templates/apps/api/src/services/project-listings.ts +602 -29
  76. package/templates/apps/api/src/vercel-handler.ts +60 -26
  77. package/templates/apps/mcp/.env.example +8 -0
  78. package/templates/apps/mcp/CLAUDE.md +2 -2
  79. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  80. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  81. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  82. package/templates/apps/mcp/src/config/modules.ts +14 -13
  83. package/templates/apps/mcp/src/server.ts +8 -4
  84. package/templates/apps/mcp/src/tools/index.ts +7 -22
  85. package/templates/apps/web/.env.example +15 -0
  86. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  87. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  88. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  89. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  90. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  91. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  92. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  93. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  94. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  95. package/templates/apps/web/app/[locale]/layout.tsx +13 -1
  96. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  97. package/templates/apps/web/jest.config.cjs +20 -13
  98. package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
  99. package/templates/apps/web/lib/site-brand.tsx +4 -1
  100. package/templates/apps/web/lib/site-theme.ts +74 -0
  101. package/templates/apps/web/next.config.mjs +1 -3
  102. package/templates/apps/web/package.json +1 -6
  103. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  104. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  105. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  106. package/templates/apps/web/public/favicon-16x16.png +0 -0
  107. package/templates/apps/web/public/favicon-32x32.png +0 -0
  108. package/templates/apps/web/public/favicon.ico +0 -0
  109. package/templates/apps/web/public/favicon.svg +6 -3
  110. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  111. package/templates/apps/web/public/logomark.svg +4 -0
  112. package/templates/apps/web/public/site.webmanifest +2 -2
  113. package/templates/apps/web/public/wordmark.svg +4 -0
  114. package/templates/content/_site.mdx +12 -0
  115. package/templates/database/CHANGELOG.md +94 -0
  116. package/templates/database/inbox/schema.prisma +165 -0
  117. package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
  118. package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
  119. package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
  120. package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
  121. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/db-generate-locked.sh +0 -0
  124. package/templates/package.json +1 -1
  125. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  126. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  127. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  128. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  129. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  130. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  131. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  132. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  133. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  134. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  135. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  136. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  137. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  138. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  139. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  140. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  141. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  142. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  143. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  144. package/templates/apps/api/src/lib/stripe.ts +0 -52
  145. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  146. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  147. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  148. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  149. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  150. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  151. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  152. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  153. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  154. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  155. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  156. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  157. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  158. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  159. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  160. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  161. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  162. package/templates/apps/api/src/routes/flows/README.md +0 -147
  163. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  164. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  165. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  166. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  167. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  168. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  169. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  170. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  171. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  172. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  173. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  174. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  175. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  176. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  177. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  178. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  179. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  180. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  181. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  182. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  183. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  184. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  185. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  186. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  187. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  188. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  189. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  190. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  191. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  192. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  193. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  194. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  195. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  196. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  197. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  198. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  199. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  200. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  201. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  202. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  203. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  204. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  205. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  206. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  207. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  208. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  209. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  210. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  211. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  212. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  213. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  214. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  215. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  216. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  217. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  218. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  219. package/templates/apps/api/src/services/flow-engine.ts +0 -1278
  220. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  221. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  222. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  223. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  224. package/templates/apps/api/src/services/project-listing-decision.ts +0 -490
  225. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  226. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  227. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  228. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  229. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  230. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  231. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  232. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  233. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  234. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  235. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  236. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  237. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  238. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  239. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  240. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  241. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  242. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  243. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  244. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  245. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  246. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  247. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  248. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  249. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  250. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  251. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  258. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  259. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  260. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  261. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  262. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  263. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  264. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  265. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  266. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  267. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  268. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  269. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  270. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  271. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  272. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  273. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  274. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  275. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  276. package/templates/apps/web/content/en/blog/README.txt +0 -25
  277. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  278. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  279. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  280. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  281. package/templates/apps/web/lib/source.ts +0 -34
  282. package/templates/apps/web/public/logomark-dark.svg +0 -4
  283. package/templates/apps/web/public/logomark-light.svg +0 -4
  284. package/templates/apps/web/source.config.ts +0 -78
@@ -0,0 +1,326 @@
1
+ /**
2
+ * @file project-listing-resume-email.ts
3
+ * @description The founder's resume-link email, sent the moment a listing
4
+ * draft is created (#5406) — the API side of `sendListingResumeEmail`.
5
+ *
6
+ * It is the `onDraftStarted` email every consuming site needs and every
7
+ * consuming site was building for itself: salessmyth/samhenry shipped a
8
+ * plain-text one with its sender hardcoded in `apps/web/src/lib/resend.ts` and
9
+ * its wording in `messages/en.json`, so every reword needed a rebuild. Here
10
+ * there is nothing to rebuild — every site-facing value is read from the
11
+ * site's `SiteListingConfig` row on each send.
12
+ *
13
+ * OPT-IN. A site whose config carries no `resumeEmail.fromAddress` (or no
14
+ * `resumeEmail.baseUrl`) gets NO email. Both are deliberate rather than
15
+ * defaulted: Resend only sends from a domain the site has verified, and the
16
+ * platform does not know where a site mounted its application. Together they
17
+ * mean a site already sending its own resume email does not start sending two
18
+ * merely because it upgraded.
19
+ *
20
+ * TENANT ISOLATION. The `siteId` this reads config for comes off the DRAFT
21
+ * ROW, never from the request body and never from a caller argument — so the
22
+ * email a founder receives is always addressed and worded by the site their
23
+ * application was actually filed against. `start` already resolves the site
24
+ * server-side from a public key (#5048 D2); reading it back off the row is the
25
+ * belt to that braces, and is why this function takes only a listing id.
26
+ *
27
+ * RATE-BOUNDED PER RECIPIENT (PR #5411 security review, finding 1). `start` is
28
+ * anonymous and its IP limiter is evadable (`trustProxy` is on — see the note
29
+ * beside the route registration in `server.ts`), so before this change the
30
+ * cost of an unbounded call was one row and no outbound anything. Sending mail
31
+ * from the site's own verified domain to an address the CALLER typed changes
32
+ * that: looped, it is a mail bomb aimed at a named victim and a deliverability
33
+ * attack on the site's sending domain. `RESUME_EMAILS_PER_RECIPIENT_PER_WINDOW`
34
+ * bounds it.
35
+ *
36
+ * The cap is keyed on an UNVERIFIED identifier, which lesson 14
37
+ * (`docs/lessons/project-listings/14-…`) warns is a lockout weapon. Two things
38
+ * make it a proportionate one here, and both are deliberate: the window is one
39
+ * HOUR rather than forever, so any lockout clears itself; and what a
40
+ * suppressed send denies is only the EMAIL — `start` still returns the resume
41
+ * token in its 201 body, so the founder in front of the form loses nothing and
42
+ * completes their application as normal.
43
+ *
44
+ * RATE-BOUNDED PER SITE TOO (#5414). The bound above is per RECIPIENT, which
45
+ * stops a loop aimed at one victim's inbox and does nothing about a loop over a
46
+ * thousand strangers — each of whom receives one message from the site's own
47
+ * verified domain. That is the send pattern a mailbox provider reads as a list
48
+ * blast, and the cost lands on the site rather than on the caller: a flagged
49
+ * sending domain and a Resend account that can be suspended, which stops EVERY
50
+ * email the site sends and not only this one.
51
+ * `SITE_RESUME_EMAILS_PER_WINDOW` bounds the site's total over the same hour,
52
+ * and a site raises it for itself with `resumeEmail.maxSendsPerHour`.
53
+ *
54
+ * WHAT THE SITE BUDGET COUNTS, and its one honest cost: the DRAFTS created for
55
+ * the site inside the window, the same shape the per-recipient bound counts and
56
+ * for the same reason — this function runs exactly once per created draft, so
57
+ * drafts are one-to-one with emails ATTEMPTED. No new table, no migration, and
58
+ * the count holds across server instances because it is a database read rather
59
+ * than process memory.
60
+ *
61
+ * Its cost, stated at full strength because the weak form of it is misleading:
62
+ * a flood of drafts spends the budget even though those drafts sent no mail, so
63
+ * at the cap a GENUINE founder's email is suppressed too — and the cheapest way
64
+ * to cause that is not a mail flood at all. The per-recipient bound above
65
+ * returns BEFORE this count is reached, so one caller using ONE address can
66
+ * open cap+1 drafts, have three messages actually delivered (to their own
67
+ * inbox), and silence the site's resume email for the rest of the hour. The
68
+ * denial is cheaper than the abuse it prevents.
69
+ *
70
+ * It is accepted rather than overlooked, for the reasons lesson 14 already
71
+ * accepted for the per-recipient bound: the window is an hour rather than
72
+ * forever, so it clears itself; and what a suppressed send denies is only the
73
+ * EMAIL — the draft is created and the 201 still carries the resume token, so
74
+ * the founder in front of the form completes their application as normal. What
75
+ * it denies is the founder who closes the tab, for at most an hour. Narrowing
76
+ * it means counting SENDS rather than drafts, which needs somewhere to record a
77
+ * send — a schema change this issue deliberately stops short of (see the PR).
78
+ *
79
+ * PII boundary (the rule `routes/project-listings/start.ts` states in its own
80
+ * header): this module logs `siteId`, `listingId`, a reason, an error KIND, and
81
+ * the link's HOST. Never the founder's address or name, never the resume
82
+ * token, never the #5407 verification NONCE, and never the assembled link —
83
+ * which carries both. The nonce is the stricter of the two: the resume token
84
+ * also went out in `start`'s 201 body, but this message is the ONLY place the
85
+ * nonce ever leaves the server, so a log line quoting the link would not just
86
+ * disclose a credential, it would destroy the property the nonce exists to
87
+ * establish. Resend's own `error.message` is untrusted (it can quote an
88
+ * address), so only `error.kind` is logged.
89
+ */
90
+
91
+ import { sendListingResumeEmail } from "@working-theory/email";
92
+ import { logger } from "@working-theory/observability";
93
+
94
+ import { prisma } from "../lib/prisma.js";
95
+
96
+ import { listingConfigFor } from "./listing-config.js";
97
+
98
+ /**
99
+ * How many resume emails one address may receive from one site within
100
+ * `RESUME_EMAIL_WINDOW_MS`.
101
+ *
102
+ * Three, not one: a founder who closes the tab and starts again has genuinely
103
+ * begun a second draft and genuinely needs a second link. Three of those in an
104
+ * hour is generous for a person and useless for a flood.
105
+ */
106
+ const RESUME_EMAILS_PER_RECIPIENT_PER_WINDOW = 3;
107
+
108
+ /**
109
+ * How many resume emails ONE SITE may send in total across every recipient
110
+ * within `RESUME_EMAIL_WINDOW_MS`, when the site has not set its own
111
+ * `resumeEmail.maxSendsPerHour` (#5414).
112
+ *
113
+ * Twenty, sized against a small site's real application volume rather than
114
+ * against an attacker: a site running this feature takes a handful of listing
115
+ * applications a day, so twenty inside a single HOUR is already a launch-day
116
+ * spike, and a site with genuine volume raises the number for itself. Sized
117
+ * from the other end it is a brake rather than a wall — it bounds a flood at
118
+ * a few hundred messages a day instead of thousands, which is the difference
119
+ * between a sending domain an operator can still explain and one a mailbox
120
+ * provider has already decided about. The operator is told when it binds.
121
+ *
122
+ * It is a platform default and deliberately not any site's terms — the same
123
+ * posture `siteListingConfigSchema`'s own defaults take.
124
+ */
125
+ const SITE_RESUME_EMAILS_PER_WINDOW = 20;
126
+
127
+ /** One hour. The cap is a rolling window so any lockout expires on its own. */
128
+ const RESUME_EMAIL_WINDOW_MS = 60 * 60 * 1000;
129
+
130
+ /**
131
+ * When each site's budget was last reported, so the cap logs ONCE per window
132
+ * rather than once per suppressed send.
133
+ *
134
+ * A line per suppressed send would hand the same caller a log-flooding
135
+ * primitive on top of the mail flood, which is the opposite of what this
136
+ * control is for. Per PROCESS rather than per deployment, so N instances emit
137
+ * at most N lines an hour — the CAP is what has to hold across instances (it
138
+ * is counted in the database), and this is only its volume knob.
139
+ *
140
+ * The key space is the deployment's configured sites, so it does not grow with
141
+ * traffic.
142
+ */
143
+ const siteCapLastLoggedAt = new Map<string, number>();
144
+
145
+ function logSiteCapOncePerWindow(siteId: string, count: number): void {
146
+ const now = Date.now();
147
+ const last = siteCapLastLoggedAt.get(siteId);
148
+ if (last !== undefined && now - last < RESUME_EMAIL_WINDOW_MS) return;
149
+ siteCapLastLoggedAt.set(siteId, now);
150
+ // `siteId` and the count, and a constant naming which bound fired. No
151
+ // address, no founder name, no resume token, no link and no LISTING ID —
152
+ // this line is about a site's aggregate budget, and the draft that happened
153
+ // to trip it identifies a person the sibling lines deliberately do not name.
154
+ logger.warn({ siteId, count, reason: "site_rate_limited" }, "listing-resume-email-skipped");
155
+ }
156
+
157
+ /**
158
+ * The link back into the application.
159
+ *
160
+ * `?draft=<id>&token=<resumeToken>&verify=<verifyNonce>` is the exact shape
161
+ * `ListingApplicationFlow`'s `resume` prop reads. Assembled through `URL` so a
162
+ * `baseUrl` that already carries a query string gains three parameters rather
163
+ * than a second `?`, and so every value is percent-encoded.
164
+ *
165
+ * `verify` (#5407) is the one credential THIS MESSAGE IS THE ONLY CARRIER OF.
166
+ * The draft id and the resume token were both in `start`'s 201 body, so the
167
+ * browser that filled the form already holds them; the nonce was not, and is
168
+ * what lets the server distinguish "someone opened this draft" from "someone
169
+ * read mail at this address". That makes this link the sole path between the
170
+ * nonce and the outside world — which is why nothing in this module logs it,
171
+ * and why the log line below carries the link's HOST and never its query.
172
+ */
173
+ function resumeLinkFor(
174
+ baseUrl: string,
175
+ draftId: string,
176
+ resumeToken: string,
177
+ verifyNonce: string,
178
+ ): string {
179
+ const url = new URL(baseUrl);
180
+ url.searchParams.set("draft", draftId);
181
+ url.searchParams.set("token", resumeToken);
182
+ url.searchParams.set("verify", verifyNonce);
183
+ return url.toString();
184
+ }
185
+
186
+ /**
187
+ * Send the resume email for a just-created draft. Never throws, and never
188
+ * reports success — the caller does not wait on it (see
189
+ * `sendListingResumeLink` below, which is what routes actually call).
190
+ */
191
+ async function send(
192
+ listingId: string,
193
+ resumeToken: string,
194
+ verifyNonce: string,
195
+ ): Promise<void> {
196
+ const row = await prisma.projectListing.findFirst({
197
+ where: { id: listingId },
198
+ // `name` is null at step one, so `{projectName}` renders empty there. It
199
+ // is selected anyway because this function is about a ROW, not about a
200
+ // moment: a later caller on a named draft substitutes a real name with no
201
+ // change here.
202
+ select: { siteId: true, founderEmail: true, name: true, founderFirstName: true },
203
+ });
204
+ if (!row) return;
205
+
206
+ const config = await listingConfigFor(row.siteId);
207
+ const settings = config.resumeEmail;
208
+
209
+ if (settings?.fromAddress === undefined || settings.baseUrl === undefined) {
210
+ logger.info(
211
+ {
212
+ siteId: row.siteId,
213
+ listingId,
214
+ reason: settings?.fromAddress === undefined ? "no_from_address" : "no_base_url",
215
+ },
216
+ "listing-resume-email-skipped",
217
+ );
218
+ return;
219
+ }
220
+
221
+ const windowStart = new Date(Date.now() - RESUME_EMAIL_WINDOW_MS);
222
+
223
+ // Counts the DRAFTS this address has started on this site in the window,
224
+ // which is one-to-one with the emails it has been sent: this function runs
225
+ // exactly once per created draft. `founderEmail` is already normalized
226
+ // (trimmed and lowercased) at write time, so the count cannot be evaded with
227
+ // the shift key — the fix lesson 14 records.
228
+ const recentForRecipient = await prisma.projectListing.count({
229
+ where: {
230
+ siteId: row.siteId,
231
+ founderEmail: row.founderEmail,
232
+ createdAt: { gt: windowStart },
233
+ },
234
+ });
235
+ // `>` not `>=`: the draft that triggered this send is itself in the count.
236
+ if (recentForRecipient > RESUME_EMAILS_PER_RECIPIENT_PER_WINDOW) {
237
+ logger.warn(
238
+ { siteId: row.siteId, listingId, reason: "recipient_rate_limited" },
239
+ "listing-resume-email-skipped",
240
+ );
241
+ return;
242
+ }
243
+
244
+ // The same count without the address clause — the site's whole outbound
245
+ // budget for the window (#5414). `siteId` comes off the DRAFT ROW, as every
246
+ // read in this function does, so one site's flood can never spend another's
247
+ // budget however the caller addressed the request.
248
+ //
249
+ // An invalid `maxSendsPerHour` is already `undefined` by the time it reaches
250
+ // here (its own `.catch(undefined)` in the schema), so a bad cap lands on the
251
+ // platform default rather than on zero — and `listingConfigFor` has already
252
+ // logged that the operator's value is not in effect.
253
+ const siteCap = settings.maxSendsPerHour ?? SITE_RESUME_EMAILS_PER_WINDOW;
254
+ const recentForSite = await prisma.projectListing.count({
255
+ where: { siteId: row.siteId, createdAt: { gt: windowStart } },
256
+ });
257
+ if (recentForSite > siteCap) {
258
+ logSiteCapOncePerWindow(row.siteId, recentForSite);
259
+ return;
260
+ }
261
+
262
+ // `maxSendsPerHour` is the API's to consume, like `baseUrl` beside it — the
263
+ // email package is handed COPY, and a budget is not copy.
264
+ const { baseUrl, maxSendsPerHour: _cap, ...copy } = settings;
265
+ // The HOST only (security review, finding 2). An operator who repoints
266
+ // `baseUrl` at a third party would otherwise exfiltrate every founder's
267
+ // resume token — the sole credential on their draft — with nothing anywhere
268
+ // recording it. The host is not a secret; the path and query are, because
269
+ // the query carries the token.
270
+ logger.info(
271
+ { siteId: row.siteId, listingId, linkHost: new URL(baseUrl).host },
272
+ "listing-resume-email-sending",
273
+ );
274
+ const result = await sendListingResumeEmail({
275
+ to: row.founderEmail,
276
+ resumeLink: resumeLinkFor(baseUrl, listingId, resumeToken, verifyNonce),
277
+ settings: copy,
278
+ variables: {
279
+ // The site's own display name, or absent — `{siteName}` then renders as
280
+ // empty text rather than as a platform name the site never chose.
281
+ ...(config.siteName !== undefined && { siteName: config.siteName }),
282
+ ...(row.name !== null && { projectName: row.name }),
283
+ // #5407 — `{firstName}` now has a source, and only where the founder
284
+ // actually gave one. `founderName` is still deliberately NOT split to
285
+ // guess a first name: a split on the first space is wrong for most of
286
+ // the world's names, and getting a founder's name wrong in the first
287
+ // email they receive is worse than omitting it. A row whose site asks
288
+ // for one display line renders `{firstName}` empty, exactly as before.
289
+ ...(row.founderFirstName !== null && { firstName: row.founderFirstName }),
290
+ },
291
+ });
292
+
293
+ if (!result.ok) {
294
+ logger.warn(
295
+ { siteId: row.siteId, listingId, errorKind: result.error.kind },
296
+ "listing resume email send failed",
297
+ );
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Fire-and-forget entry point — what `start` calls, and what makes the 201 on
303
+ * that route independent of Resend.
304
+ *
305
+ * Returns `void` rather than a promise so a caller cannot accidentally
306
+ * `await` it, and `.catch`-guards everything: `send` above can reject (a
307
+ * Prisma read, a malformed `baseUrl` reaching `new URL`) where
308
+ * `sendListingResumeEmail` itself never does, and nothing from either may
309
+ * reach a route whose row is already committed.
310
+ *
311
+ * Mirrors `project-listing-decision.ts#fileIssueOnApprove`'s shape, which is
312
+ * the same promise on the same feature: a best-effort side effect of a write
313
+ * that has already landed.
314
+ */
315
+ export function sendListingResumeLink(
316
+ listingId: string,
317
+ resumeToken: string,
318
+ verifyNonce: string,
319
+ ): void {
320
+ send(listingId, resumeToken, verifyNonce).catch((err: unknown) => {
321
+ logger.warn(
322
+ { listingId, errName: err instanceof Error ? err.name : "UnknownError" },
323
+ "listing resume email rejected unexpectedly",
324
+ );
325
+ });
326
+ }