create-ailk 0.2.0 → 0.4.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 (292) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/cli.js +19 -9
  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 +51 -3
  6. package/dist/lib/extract-module-bundle.js +327 -28
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/fetch-template.d.ts +20 -0
  10. package/dist/lib/fetch-template.js +129 -0
  11. package/dist/lib/module-license-gate.d.ts +10 -0
  12. package/dist/lib/module-license-gate.js +13 -4
  13. package/dist/lib/paid-paths.d.ts +16 -0
  14. package/dist/lib/paid-paths.js +90 -0
  15. package/dist/module-architecture.d.ts +197 -23
  16. package/dist/module-architecture.js +820 -25
  17. package/dist/parse-args.d.ts +9 -2
  18. package/dist/parse-args.js +46 -1
  19. package/dist/programmatic.d.ts +28 -2
  20. package/dist/programmatic.js +115 -8
  21. package/dist/surfaces.d.ts +12 -36
  22. package/dist/surfaces.js +77 -5
  23. package/dist/sync-routes.js +31 -1
  24. package/dist/template-manifest.d.ts +92 -0
  25. package/dist/template-manifest.js +168 -0
  26. package/dist/template-names.d.ts +41 -0
  27. package/dist/template-names.js +44 -0
  28. package/package.json +3 -2
  29. package/site-templates/marketing-site-free/ailk-template.json +8 -0
  30. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +103 -0
  31. package/site-templates/marketing-site-free/content/en/PrivacyPage/privacy.mdx +79 -0
  32. package/site-templates/marketing-site-free/content/en/TermsPage/terms.mdx +80 -0
  33. package/site-templates/marketing-site-free/provenance.json +4 -0
  34. package/templates/.claude/agents/web.md +2 -3
  35. package/templates/.claude/rules/architecture.md +5 -5
  36. package/templates/.claude/skills/README.md +2 -1
  37. package/templates/.env.example +21 -9
  38. package/templates/CONVENTIONS.md +6 -7
  39. package/templates/apps/api/.env.example +9 -0
  40. package/templates/apps/api/CLAUDE.md +33 -5
  41. package/templates/apps/api/package.json +0 -1
  42. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  43. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  44. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  45. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +166 -0
  46. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  47. package/templates/apps/api/src/config/index.ts +19 -0
  48. package/templates/apps/api/src/config/modules.ts +12 -14
  49. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  50. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  51. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  52. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  53. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  54. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  55. package/templates/apps/api/src/openapi/index.ts +9 -3
  56. package/templates/apps/api/src/openapi/spec.ts +505 -65
  57. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  58. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  59. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  60. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  61. package/templates/apps/api/src/routes/content/index.ts +38 -11
  62. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  63. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  64. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  65. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  66. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  67. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  68. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  70. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  71. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  72. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  73. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  74. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  75. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  76. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  77. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  78. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  79. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  80. package/templates/apps/api/src/server.ts +504 -137
  81. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  82. package/templates/apps/api/src/services/listing-config.ts +58 -0
  83. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  84. package/templates/apps/api/src/services/project-listings.ts +229 -4
  85. package/templates/apps/api/src/vercel-handler.ts +60 -26
  86. package/templates/apps/mcp/.env.example +8 -0
  87. package/templates/apps/mcp/CLAUDE.md +2 -2
  88. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  89. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  90. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  91. package/templates/apps/mcp/src/config/modules.ts +14 -13
  92. package/templates/apps/mcp/src/server.ts +8 -4
  93. package/templates/apps/mcp/src/tools/index.ts +7 -22
  94. package/templates/apps/web/.env.example +15 -0
  95. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  96. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  97. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  98. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  102. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  104. package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
  105. package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
  106. package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
  107. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  108. package/templates/apps/web/jest.config.cjs +19 -13
  109. package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
  110. package/templates/apps/web/lib/server-api.ts +28 -12
  111. package/templates/apps/web/messages/en.json +58 -0
  112. package/templates/apps/web/next.config.mjs +1 -3
  113. package/templates/apps/web/package.json +0 -6
  114. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  115. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  116. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  117. package/templates/apps/web/public/favicon-16x16.png +0 -0
  118. package/templates/apps/web/public/favicon-32x32.png +0 -0
  119. package/templates/apps/web/public/favicon.ico +0 -0
  120. package/templates/apps/web/public/favicon.svg +6 -3
  121. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  122. package/templates/apps/web/public/logomark.svg +4 -0
  123. package/templates/apps/web/public/site.webmanifest +2 -2
  124. package/templates/apps/web/public/wordmark.svg +4 -0
  125. package/templates/database/CHANGELOG.md +47 -0
  126. package/templates/database/inbox/schema.prisma +67 -0
  127. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  128. package/templates/database/package.json +1 -1
  129. package/templates/package.json +1 -1
  130. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  131. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  132. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  133. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  134. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  135. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  136. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  137. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  138. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  139. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  140. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  141. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  142. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  143. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  144. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  145. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  146. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  147. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  148. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  149. package/templates/apps/api/src/lib/stripe.ts +0 -52
  150. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  151. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  152. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  153. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  154. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  155. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  156. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  157. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  158. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  159. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  160. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  161. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  162. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  163. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  164. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  165. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  166. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  167. package/templates/apps/api/src/routes/flows/README.md +0 -147
  168. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  169. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  170. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  171. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  172. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  173. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  174. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  175. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  176. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  177. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  178. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  179. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  180. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  181. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  182. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  183. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  184. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  185. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  186. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  187. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  188. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  189. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  190. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  191. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  192. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  193. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  194. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  195. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  196. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  197. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  198. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  199. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  200. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  201. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  202. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  203. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  204. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  205. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  206. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  207. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  208. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  209. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  210. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  211. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  212. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  213. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  214. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  215. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  216. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  217. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  218. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  219. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  220. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  221. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  222. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  223. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  224. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  225. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  226. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  227. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  228. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  229. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  230. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  231. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  232. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  233. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  234. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  235. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  236. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  237. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  238. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  239. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  240. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  241. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  242. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  243. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  244. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  245. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  246. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  247. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  248. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  249. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  250. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  251. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  258. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  259. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  260. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  261. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  262. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  263. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  264. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  265. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  266. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  267. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  268. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  269. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  270. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  271. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  272. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  273. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  274. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  275. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  276. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  277. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  278. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  279. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  280. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  281. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  282. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  283. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  284. package/templates/apps/web/content/en/blog/README.txt +0 -25
  285. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  286. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  287. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  288. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  289. package/templates/apps/web/lib/source.ts +0 -34
  290. package/templates/apps/web/public/logomark-dark.svg +0 -4
  291. package/templates/apps/web/public/logomark-light.svg +0 -4
  292. package/templates/apps/web/source.config.ts +0 -78
@@ -3,6 +3,16 @@
3
3
  * @description The deliverable-fulfillment service (#3961 D3/D4/D5/D7;
4
4
  * extended #3998 D1/D3/D5; extended FEAT-074 U4b, #4718 D4).
5
5
  *
6
+ * NOT marked `oss: false` and NOT in signal-collectors's `ownedFiles`
7
+ * (module-architecture.ts), despite D-48/D6 placing this file with
8
+ * signal-collectors — `services/recommender-capture.ts` imports it directly
9
+ * and is itself reached from `server.ts` by a plain, unconditional static
10
+ * import today (commerce/#5364 has not yet landed the gate around it).
11
+ * Marking or excluding this file broke both the OSS-strip and the create-ailk
12
+ * starter snapshot for EVERY module, not just signal-collectors (spec §5.4
13
+ * escalation boundary; see module-architecture.ts's comment on this file).
14
+ * Revert once #5364 lands.
15
+ *
6
16
  * `fulfillDeliverable` is the one entry point, called from flow-engine's
7
17
  * completion branch after `routeLead` succeeds and only when the session's
8
18
  * `configSnapshot` carries a `deliverables` block (D4 — the block is opt-in
@@ -1,4 +1,10 @@
1
1
  /**
2
+ * NOT marked `oss: false` (D-48/#5366 escalation — see
3
+ * `routes/project-listings/me.ts`'s header comment): imported by
4
+ * `routes/project-listings/{start,public}.ts`, each of which ships
5
+ * unconditionally because `routes/project-listings/index.ts`'s multi-tenant
6
+ * literal `import()` forces it. Registration stays gated.
7
+ *
2
8
  * @file listing-config.ts
3
9
  * @description The per-site project-listings config read (#5033) — one
4
10
  * function, `listingConfigFor`, that resolves a site's window bounds and
@@ -53,9 +59,61 @@ export async function listingConfigFor(siteId: string): Promise<SiteListingConfi
53
59
  );
54
60
  return SITE_LISTING_CONFIG_DEFAULTS;
55
61
  }
62
+ warnOnDroppedField(siteId, row.data, parsed.data);
56
63
  return parsed.data;
57
64
  }
58
65
 
66
+ /**
67
+ * The two `.catch(undefined)` members of `siteListingConfigSchema` are silent
68
+ * by construction (#5406): a malformed `resumeEmail` or `siteName` is DROPPED
69
+ * rather than failing the whole blob, so the site keeps its own window bounds
70
+ * and collected fields instead of silently reverting to the platform's more
71
+ * permissive defaults.
72
+ *
73
+ * Silent is the right BEHAVIOUR and the wrong OBSERVABILITY. An operator who
74
+ * typos a subject key gets a site that quietly stops sending the resume email
75
+ * with nothing anywhere saying why. This closes that: when the stored blob has
76
+ * the key and the parsed config does not, the drop is logged.
77
+ *
78
+ * Field NAMES only, never values — `resumeEmail` carries the site's addresses
79
+ * and the founder-facing copy, and this module sits on the anonymous path.
80
+ */
81
+ function warnOnDroppedField(
82
+ siteId: string,
83
+ stored: unknown,
84
+ parsed: SiteListingConfig,
85
+ ): void {
86
+ if (typeof stored !== "object" || stored === null) return;
87
+ const raw = stored as Record<string, unknown>;
88
+ for (const field of ["resumeEmail", "siteName"] as const) {
89
+ if (raw[field] !== undefined && parsed[field] === undefined) {
90
+ logger.warn(
91
+ { siteId, field },
92
+ "[listing-config] stored field failed its schema and was DROPPED — the rest of this site's config still applies, but this field is not in effect",
93
+ );
94
+ }
95
+ }
96
+
97
+ // `resumeEmail.maxSendsPerHour` carries its OWN `.catch(undefined)` (#5414),
98
+ // so a bad cap is dropped while the block around it survives — which the
99
+ // loop above, comparing whole members, cannot see. Checked only when the
100
+ // block itself parsed: a dropped block is already reported once above, and
101
+ // reporting it twice would make the narrower line the less trustworthy one.
102
+ const storedEmail = raw["resumeEmail"];
103
+ if (
104
+ typeof storedEmail === "object" &&
105
+ storedEmail !== null &&
106
+ (storedEmail as Record<string, unknown>)["maxSendsPerHour"] !== undefined &&
107
+ parsed.resumeEmail !== undefined &&
108
+ parsed.resumeEmail.maxSendsPerHour === undefined
109
+ ) {
110
+ logger.warn(
111
+ { siteId, field: "resumeEmail.maxSendsPerHour" },
112
+ "[listing-config] stored field failed its schema and was DROPPED — the rest of this site's config still applies, but this field is not in effect",
113
+ );
114
+ }
115
+ }
116
+
59
117
  /**
60
118
  * Resolve a site from its PUBLIC SITE KEY (#5048 D2).
61
119
  *
@@ -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
+ }