create-ailk 0.1.0 → 0.1.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 (605) hide show
  1. package/README.md +124 -3
  2. package/component-catalog.md +295 -0
  3. package/dist/cli.js +92 -16
  4. package/dist/component-catalog.d.ts +11 -0
  5. package/dist/component-catalog.js +39 -0
  6. package/dist/copy.d.ts +7 -0
  7. package/dist/copy.js +24 -0
  8. package/dist/derive-architecture.d.ts +13 -0
  9. package/dist/derive-architecture.js +238 -0
  10. package/dist/index.d.ts +7 -2
  11. package/dist/index.js +19 -2
  12. package/dist/lib/extract-module-bundle.d.ts +16 -0
  13. package/dist/lib/extract-module-bundle.js +184 -0
  14. package/dist/lib/fetch-module.d.ts +55 -0
  15. package/dist/lib/fetch-module.js +125 -0
  16. package/dist/lib/module-license-gate.d.ts +48 -0
  17. package/dist/lib/module-license-gate.js +67 -0
  18. package/dist/lib/module-tier.d.ts +35 -0
  19. package/dist/lib/module-tier.js +37 -0
  20. package/dist/module-architecture.d.ts +175 -0
  21. package/dist/module-architecture.js +663 -0
  22. package/dist/parse-args.d.ts +47 -1
  23. package/dist/parse-args.js +143 -2
  24. package/dist/programmatic.d.ts +160 -3
  25. package/dist/programmatic.js +196 -7
  26. package/dist/surfaces.d.ts +211 -0
  27. package/dist/surfaces.js +532 -0
  28. package/dist/sync-routes.d.ts +92 -0
  29. package/dist/sync-routes.js +350 -0
  30. package/package.json +22 -14
  31. package/templates/.claude/rules/architecture.md +6 -2
  32. package/templates/.claude/skills/README.md +9 -9
  33. package/templates/.claude/skills/add-schema/SKILL.md +14 -7
  34. package/templates/.claude/skills/add-schema/references/schema-types.md +109 -6
  35. package/templates/.claude/skills/provision-config/SKILL.md +1 -1
  36. package/templates/.claude/skills/scaffold-commerce/SKILL.md +498 -1
  37. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +370 -0
  38. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +376 -0
  39. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +471 -0
  40. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +365 -0
  41. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +60 -0
  42. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +446 -0
  43. package/templates/.claude/skills/suggest-site-pages/references/categories.md +4 -4
  44. package/templates/.env.example +126 -2
  45. package/templates/CLAUDE.md +11 -5
  46. package/templates/README.md +1 -1
  47. package/templates/apps/api/.env.example +38 -0
  48. package/templates/apps/api/CLAUDE.md +75 -1
  49. package/templates/apps/api/LICENSE +201 -0
  50. package/templates/apps/api/api/index.ts +6 -141
  51. package/templates/apps/api/jest.config.cjs +16 -0
  52. package/templates/apps/api/package.json +22 -6
  53. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +141 -0
  54. package/templates/apps/api/src/__tests__/server.test.ts +46 -0
  55. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +211 -0
  56. package/templates/apps/api/src/bin/abandon-sweep.ts +42 -0
  57. package/templates/apps/api/src/bin/deliverable-resend.ts +144 -0
  58. package/templates/apps/api/src/bin/deliverable-upload.ts +114 -0
  59. package/templates/apps/api/src/bin/followup-sweep.ts +44 -0
  60. package/templates/apps/api/src/bin/listing-csv-sweep.ts +46 -0
  61. package/templates/apps/api/src/bin/seed-presets.ts +58 -0
  62. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +104 -0
  63. package/templates/apps/api/src/bin/session-retention-sweep.ts +46 -0
  64. package/templates/apps/api/src/cli.ts +5 -11
  65. package/templates/apps/api/src/config/index.ts +35 -0
  66. package/templates/apps/api/src/config/modules.ts +43 -0
  67. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +11 -0
  68. package/templates/apps/api/src/lib/__mocks__/prisma.ts +231 -0
  69. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +2 -2
  70. package/templates/apps/api/src/lib/site.ts +16 -0
  71. package/templates/apps/api/src/lib/stripe.ts +22 -0
  72. package/templates/apps/api/src/lib/tenant-db.ts +225 -0
  73. package/templates/apps/api/src/lib/ws-token.ts +91 -0
  74. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +7 -1
  75. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +7 -1
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +91 -4
  77. package/templates/apps/api/src/middleware/auth.ts +43 -8
  78. package/templates/apps/api/src/middleware/rate-limit.ts +78 -3
  79. package/templates/apps/api/src/middleware/tenant.ts +99 -0
  80. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +92 -0
  81. package/templates/apps/api/src/openapi/spec.ts +312 -3
  82. package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +175 -0
  83. package/templates/apps/api/src/routes/aeo/index.ts +107 -0
  84. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +5 -2
  85. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +22 -3
  86. package/templates/apps/api/src/routes/auth/tokens.ts +13 -1
  87. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +450 -0
  88. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +262 -0
  89. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +436 -0
  90. package/templates/apps/api/src/routes/billing/index.ts +36 -0
  91. package/templates/apps/api/src/routes/billing/portal.ts +182 -0
  92. package/templates/apps/api/src/routes/billing/read.ts +75 -0
  93. package/templates/apps/api/src/routes/billing/usage.ts +153 -0
  94. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +99 -0
  95. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -8
  96. package/templates/apps/api/src/routes/content/create.ts +1 -0
  97. package/templates/apps/api/src/routes/content/delete.ts +1 -0
  98. package/templates/apps/api/src/routes/content/index.ts +42 -6
  99. package/templates/apps/api/src/routes/content/update.ts +1 -0
  100. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +393 -0
  101. package/templates/apps/api/src/routes/deliverables/index.ts +200 -0
  102. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +443 -0
  103. package/templates/apps/api/src/routes/flow-checkouts/index.ts +82 -0
  104. package/templates/apps/api/src/routes/flows/README.md +147 -0
  105. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +752 -0
  106. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +671 -0
  107. package/templates/apps/api/src/routes/flows/index.ts +202 -0
  108. package/templates/apps/api/src/routes/flows/recommender.ts +443 -0
  109. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +438 -31
  110. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +10 -10
  111. package/templates/apps/api/src/routes/leads/index.ts +43 -9
  112. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +547 -0
  113. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +815 -0
  114. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +742 -0
  115. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +424 -0
  116. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +364 -0
  117. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +566 -0
  118. package/templates/apps/api/src/routes/project-listings/get.ts +70 -0
  119. package/templates/apps/api/src/routes/project-listings/index.ts +105 -0
  120. package/templates/apps/api/src/routes/project-listings/list.ts +47 -0
  121. package/templates/apps/api/src/routes/project-listings/patch-copy.ts +222 -0
  122. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +97 -0
  123. package/templates/apps/api/src/routes/project-listings/patch.ts +136 -0
  124. package/templates/apps/api/src/routes/project-listings/public.ts +52 -0
  125. package/templates/apps/api/src/routes/project-listings/respond.ts +37 -0
  126. package/templates/apps/api/src/routes/project-listings/resume-token.ts +22 -0
  127. package/templates/apps/api/src/routes/project-listings/start.ts +98 -0
  128. package/templates/apps/api/src/routes/project-listings/submit.ts +122 -0
  129. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +490 -0
  130. package/templates/apps/api/src/routes/schedule/index.ts +249 -0
  131. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +385 -0
  132. package/templates/apps/api/src/routes/slack/actions.ts +177 -0
  133. package/templates/apps/api/src/routes/slack/index.ts +38 -0
  134. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +16 -6
  135. package/templates/apps/api/src/routes/test/auth/session.ts +8 -6
  136. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +577 -0
  137. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +78 -0
  138. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +41 -0
  139. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +64 -0
  140. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +42 -0
  141. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +84 -0
  142. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +119 -0
  143. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +238 -0
  144. package/templates/apps/api/src/routes/waitlist-signups/index.ts +101 -0
  145. package/templates/apps/api/src/routes/webhooks/README.md +35 -12
  146. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +315 -0
  147. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +270 -0
  148. package/templates/apps/api/src/routes/webhooks/stripe.ts +248 -20
  149. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +356 -0
  150. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +671 -0
  151. package/templates/apps/api/src/routes/workspaces/config.ts +125 -0
  152. package/templates/apps/api/src/routes/workspaces/index.ts +194 -0
  153. package/templates/apps/api/src/routes/workspaces/sessions.ts +218 -0
  154. package/templates/apps/api/src/server.ts +293 -7
  155. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +489 -0
  156. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +475 -0
  157. package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +132 -0
  158. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +523 -0
  159. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +359 -0
  160. package/templates/apps/api/src/services/__tests__/entitlements.test.ts +142 -0
  161. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +425 -0
  162. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +2840 -0
  163. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +393 -0
  164. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +8 -8
  165. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +560 -0
  166. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +406 -0
  167. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +290 -0
  168. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +736 -0
  169. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +475 -0
  170. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +340 -0
  171. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +983 -0
  172. package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +278 -0
  173. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +71 -0
  174. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +379 -0
  175. package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +941 -0
  176. package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +588 -0
  177. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +768 -0
  178. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +1314 -0
  179. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +341 -0
  180. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +567 -0
  181. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +474 -0
  182. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +354 -0
  183. package/templates/apps/api/src/services/abandon-email.ts +307 -0
  184. package/templates/apps/api/src/services/aeo-score.ts +288 -0
  185. package/templates/apps/api/src/services/answer-distributions.ts +138 -0
  186. package/templates/apps/api/src/services/deliverable-fulfillment.ts +523 -0
  187. package/templates/apps/api/src/services/entitlements.ts +102 -0
  188. package/templates/apps/api/src/services/flow-aggregate.ts +232 -0
  189. package/templates/apps/api/src/services/flow-checkouts.ts +123 -0
  190. package/templates/apps/api/src/services/flow-engine.ts +1278 -0
  191. package/templates/apps/api/src/services/lead-promotion.ts +176 -0
  192. package/templates/apps/api/src/services/lead-routing.ts +3 -3
  193. package/templates/apps/api/src/services/listing-config.ts +102 -0
  194. package/templates/apps/api/src/services/listing-csv-sweep.ts +455 -0
  195. package/templates/apps/api/src/services/playbook-compile.ts +398 -0
  196. package/templates/apps/api/src/services/playbook-render.ts +263 -0
  197. package/templates/apps/api/src/services/project-listing-decision.ts +490 -0
  198. package/templates/apps/api/src/services/project-listing-flow.ts +426 -0
  199. package/templates/apps/api/src/services/project-listing-issue.ts +251 -0
  200. package/templates/apps/api/src/services/project-listings.ts +1124 -0
  201. package/templates/apps/api/src/services/recommender-capture.ts +835 -0
  202. package/templates/apps/api/src/services/scheduling/cal-provider.ts +392 -0
  203. package/templates/apps/api/src/services/scheduling/index.ts +63 -0
  204. package/templates/apps/api/src/services/scheduling/types.ts +88 -0
  205. package/templates/apps/api/src/services/slack-notify.ts +240 -0
  206. package/templates/apps/api/src/services/tenant-context.ts +451 -0
  207. package/templates/apps/api/src/services/usage-aggregation.ts +516 -0
  208. package/templates/apps/api/src/services/usage-credits.ts +340 -0
  209. package/templates/apps/api/src/services/usage-metering.ts +699 -0
  210. package/templates/apps/api/src/services/waitlist-dashboard.ts +947 -0
  211. package/templates/apps/api/src/services/waitlist-experiments.ts +213 -0
  212. package/templates/apps/api/src/services/waitlist-followup.ts +486 -0
  213. package/templates/apps/api/src/services/waitlist-scoring.ts +166 -0
  214. package/templates/apps/api/src/services/waitlist-signups.ts +165 -0
  215. package/templates/apps/api/src/vercel-handler.ts +165 -0
  216. package/templates/apps/api/tsconfig.json +0 -12
  217. package/templates/apps/mcp/CLAUDE.md +19 -9
  218. package/templates/apps/mcp/LICENSE +201 -0
  219. package/templates/apps/mcp/__tests__/create_page.test.ts +1 -0
  220. package/templates/apps/mcp/__tests__/delete_page.test.ts +1 -0
  221. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +105 -0
  222. package/templates/apps/mcp/__tests__/parity.test.ts +70 -6
  223. package/templates/apps/mcp/__tests__/request_callback.test.ts +4 -4
  224. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +242 -0
  225. package/templates/apps/mcp/__tests__/submit_lead.test.ts +4 -4
  226. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +4 -4
  227. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +47 -0
  228. package/templates/apps/mcp/__tests__/update_page.test.ts +1 -0
  229. package/templates/apps/mcp/package.json +6 -4
  230. package/templates/apps/mcp/src/config/modules.ts +37 -0
  231. package/templates/apps/mcp/src/server.ts +109 -18
  232. package/templates/apps/mcp/src/tools/capture_lead.ts +1 -1
  233. package/templates/apps/mcp/src/tools/create_booking.ts +58 -0
  234. package/templates/apps/mcp/src/tools/create_page.ts +1 -0
  235. package/templates/apps/mcp/src/tools/delete_page.ts +1 -0
  236. package/templates/apps/mcp/src/tools/get_event_meta.ts +55 -0
  237. package/templates/apps/mcp/src/tools/get_flow.ts +61 -0
  238. package/templates/apps/mcp/src/tools/index.ts +25 -0
  239. package/templates/apps/mcp/src/tools/list_availability.ts +67 -0
  240. package/templates/apps/mcp/src/tools/purchase.ts +3 -0
  241. package/templates/apps/mcp/src/tools/submit_flow_step.ts +83 -0
  242. package/templates/apps/mcp/src/tools/update_page.ts +1 -0
  243. package/templates/apps/mcp/tsconfig.json +1 -9
  244. package/templates/apps/web/.env.example +44 -0
  245. package/templates/apps/web/CLAUDE.md +28 -0
  246. package/templates/apps/web/LICENSE +201 -0
  247. package/templates/apps/web/__tests__/__mocks__/code-highlight.jest-stub.ts +24 -0
  248. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +33 -0
  249. package/templates/apps/web/__tests__/__mocks__/next-intl.ts +18 -0
  250. package/templates/apps/web/__tests__/__mocks__/server-only.ts +13 -0
  251. package/templates/apps/web/__tests__/__mocks__/style-mock.js +4 -0
  252. package/templates/apps/web/__tests__/__mocks__/vercel-analytics-server.ts +9 -0
  253. package/templates/apps/web/__tests__/jest-support/import-meta-transformer.cjs +90 -0
  254. package/templates/apps/web/__tests__/setup.ts +22 -32
  255. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +6 -3
  256. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +8 -2
  257. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +260 -0
  258. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +263 -0
  259. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +39 -0
  260. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +54 -0
  261. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +96 -0
  262. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +309 -0
  263. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +149 -0
  264. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +273 -0
  265. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/gate.test.tsx +172 -0
  266. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +171 -0
  267. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +179 -0
  268. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +116 -0
  269. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ForbiddenState.tsx +24 -0
  270. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +66 -0
  271. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +61 -0
  272. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +114 -0
  273. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +139 -0
  274. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +85 -0
  275. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +67 -0
  276. package/templates/apps/web/app/[locale]/(authed)/waitlist/format.ts +111 -0
  277. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +179 -0
  278. package/templates/apps/web/app/[locale]/about/page.tsx +10 -1
  279. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +6 -1
  280. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +9 -0
  281. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
  282. package/templates/apps/web/app/[locale]/careers/page.tsx +9 -1
  283. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +9 -0
  284. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
  285. package/templates/apps/web/app/[locale]/case-studies/page.tsx +13 -1
  286. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +11 -1
  287. package/templates/apps/web/app/[locale]/clients/page.tsx +9 -1
  288. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +21 -1
  289. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +188 -0
  290. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +242 -0
  291. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +29 -0
  292. package/templates/apps/web/app/[locale]/contact/__tests__/intake-analytics.test.ts +51 -0
  293. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +39 -15
  294. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +21 -3
  295. package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +49 -0
  296. package/templates/apps/web/app/[locale]/contact/intake-script.ts +66 -0
  297. package/templates/apps/web/app/[locale]/contact/page.tsx +14 -4
  298. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +13 -1
  299. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
  300. package/templates/apps/web/app/[locale]/courses/page.tsx +9 -1
  301. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +1 -0
  302. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +11 -1
  303. package/templates/apps/web/app/[locale]/downloads/page.tsx +13 -1
  304. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +9 -0
  305. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +12 -2
  306. package/templates/apps/web/app/[locale]/events/page.tsx +9 -1
  307. package/templates/apps/web/app/[locale]/faq/page.tsx +10 -1
  308. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +265 -0
  309. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +445 -0
  310. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +341 -0
  311. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +287 -0
  312. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +302 -0
  313. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +128 -0
  314. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +254 -0
  315. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +144 -0
  316. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +108 -0
  317. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +532 -0
  318. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +121 -0
  319. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +457 -0
  320. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +396 -0
  321. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +114 -0
  322. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +52 -0
  323. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +13 -1
  324. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  325. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +9 -0
  326. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
  327. package/templates/apps/web/app/[locale]/integrations/page.tsx +13 -1
  328. package/templates/apps/web/app/[locale]/launch-service/page.tsx +14 -1
  329. package/templates/apps/web/app/[locale]/layout.tsx +115 -16
  330. package/templates/apps/web/app/[locale]/page.tsx +10 -1
  331. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +9 -0
  332. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
  333. package/templates/apps/web/app/[locale]/partners/page.tsx +9 -1
  334. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +9 -0
  335. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
  336. package/templates/apps/web/app/[locale]/products/page.tsx +9 -1
  337. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +13 -1
  338. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +10 -0
  339. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +110 -0
  340. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +96 -0
  341. package/templates/apps/web/app/[locale]/projects/page.tsx +13 -1
  342. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +9 -0
  343. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +12 -2
  344. package/templates/apps/web/app/[locale]/resources/page.tsx +13 -1
  345. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +117 -0
  346. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +96 -0
  347. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +92 -0
  348. package/templates/apps/web/app/[locale]/schedule/page.tsx +82 -0
  349. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +9 -0
  350. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
  351. package/templates/apps/web/app/[locale]/services/page.tsx +9 -1
  352. package/templates/apps/web/app/[locale]/sign-in/__tests__/next-path.test.ts +31 -0
  353. package/templates/apps/web/app/[locale]/sign-in/__tests__/page.test.tsx +152 -0
  354. package/templates/apps/web/app/[locale]/sign-in/next-path.ts +0 -0
  355. package/templates/apps/web/app/[locale]/sign-in/page.tsx +83 -0
  356. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +123 -0
  357. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +13 -1
  358. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  359. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +13 -1
  360. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  361. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +11 -1
  362. package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
  363. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +93 -0
  364. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +48 -0
  365. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +11 -1
  366. package/templates/apps/web/app/[locale]/use-cases/page.tsx +13 -1
  367. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +13 -1
  368. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  369. package/templates/apps/web/app/globals.css +35 -0
  370. package/templates/apps/web/app/llms.txt/route.ts +2 -1
  371. package/templates/apps/web/app/og/route.tsx +10 -2
  372. package/templates/apps/web/auth.ts +8 -0
  373. package/templates/apps/web/components/sign-out-trigger.tsx +42 -0
  374. package/templates/apps/web/content/en/blog/README.txt +25 -0
  375. package/templates/apps/web/content/en/docs/index.mdx +28 -0
  376. package/templates/apps/web/jest.config.cjs +84 -27
  377. package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -0
  378. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +499 -0
  379. package/templates/apps/web/lib/account-config.ts +13 -0
  380. package/templates/apps/web/lib/server-api.ts +85 -0
  381. package/templates/apps/web/lib/site-brand.tsx +118 -0
  382. package/templates/apps/web/messages/en.json +275 -7
  383. package/templates/apps/web/middleware.ts +11 -3
  384. package/templates/apps/web/next.config.mjs +8 -2
  385. package/templates/apps/web/package.json +16 -7
  386. package/templates/apps/web/postcss.config.mjs +23 -0
  387. package/templates/apps/web/public/.well-known/security.txt +3 -3
  388. package/templates/apps/web/src/lib/routes.ts +15 -2
  389. package/templates/apps/web/tsconfig.json +1 -16
  390. package/templates/content/_site.mdx +24 -25
  391. package/templates/content/en/AboutPage/about.mdx +24 -14
  392. package/templates/content/en/ContactPage/contact.mdx +23 -14
  393. package/templates/content/en/FAQPage/faq.mdx +10 -392
  394. package/templates/content/en/HomePage/home.mdx +14 -285
  395. package/templates/database/CHANGELOG.md +382 -0
  396. package/templates/database/LICENSE +201 -0
  397. package/templates/database/README.md +3 -1
  398. package/templates/database/__tests__/flows-listing-diagnostic.test.ts +392 -0
  399. package/templates/database/__tests__/flows-presets.test.ts +173 -0
  400. package/templates/database/__tests__/prefixed-ids.db.test.ts +146 -0
  401. package/templates/database/__tests__/seed.test.ts +88 -0
  402. package/templates/database/__tests__/user-cascade.test.ts +19 -4
  403. package/templates/database/auth/schema.prisma +521 -0
  404. package/templates/database/config/schema.prisma +234 -0
  405. package/templates/database/content/schema.prisma +263 -0
  406. package/templates/database/flows/listing-diagnostic.ts +182 -0
  407. package/templates/database/flows/presets.ts +315 -0
  408. package/templates/database/inbox/__tests__/schema.test.ts +4 -0
  409. package/templates/database/inbox/schema.prisma +476 -3
  410. package/templates/database/jest.config.cjs +8 -0
  411. package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +201 -0
  412. package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +67 -0
  413. package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +46 -0
  414. package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +25 -0
  415. package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +49 -0
  416. package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +197 -0
  417. package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +32 -0
  418. package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +22 -0
  419. package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +151 -0
  420. package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +223 -0
  421. package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +22 -0
  422. package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +32 -0
  423. package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +40 -0
  424. package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +4 -0
  425. package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +2 -0
  426. package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +2 -0
  427. package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +29 -0
  428. package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +6 -0
  429. package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +39 -0
  430. package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +24 -0
  431. package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +3 -0
  432. package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +33 -0
  433. package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +30 -0
  434. package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +47 -0
  435. package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +16 -0
  436. package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +47 -0
  437. package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +10 -0
  438. package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +26 -0
  439. package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +90 -0
  440. package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +174 -0
  441. package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +14 -0
  442. package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +25 -0
  443. package/templates/database/ops/schema.prisma +27 -0
  444. package/templates/database/package.json +42 -4
  445. package/templates/database/prisma.config.ts +1 -1
  446. package/templates/database/schema.prisma +19 -435
  447. package/templates/database/scripts/db-generate-locked.sh +0 -0
  448. package/templates/database/seed-test.ts +14 -4
  449. package/templates/database/seed.ts +148 -0
  450. package/templates/database/tenancy.ts +45 -0
  451. package/templates/database/tsconfig.flows.json +15 -0
  452. package/templates/database/tsconfig.json +7 -1
  453. package/templates/database/tsconfig.tenancy.json +14 -0
  454. package/templates/package.json +30 -55
  455. package/templates/pnpm-workspace.yaml +0 -3
  456. package/templates/project.yaml +1 -1
  457. package/templates/tsconfig.json +1 -37
  458. package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +0 -94
  459. package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +0 -75
  460. package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +0 -82
  461. package/templates/apps/web/__tests__/auth/env-helpers.test.ts +0 -168
  462. package/templates/apps/web/__tests__/authority-list-routes.test.tsx +0 -208
  463. package/templates/apps/web/__tests__/better-stack.test.ts +0 -66
  464. package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +0 -87
  465. package/templates/apps/web/__tests__/blog-source-populated.test.ts +0 -130
  466. package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +0 -60
  467. package/templates/apps/web/__tests__/google-analytics.test.tsx +0 -104
  468. package/templates/apps/web/__tests__/i18n/middleware.test.ts +0 -119
  469. package/templates/apps/web/__tests__/i18n/routing.test.ts +0 -114
  470. package/templates/apps/web/__tests__/i18n/translations.test.ts +0 -191
  471. package/templates/apps/web/__tests__/json-ld.test.ts +0 -98
  472. package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +0 -580
  473. package/templates/apps/web/__tests__/layout-stealth.test.ts +0 -39
  474. package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +0 -103
  475. package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +0 -114
  476. package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +0 -94
  477. package/templates/apps/web/__tests__/page.test.tsx +0 -230
  478. package/templates/apps/web/__tests__/products-services-phase1.test.tsx +0 -321
  479. package/templates/apps/web/__tests__/projects-routes.test.tsx +0 -392
  480. package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +0 -475
  481. package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +0 -53
  482. package/templates/apps/web/__tests__/routes/blog-route.test.tsx +0 -191
  483. package/templates/apps/web/__tests__/routes/llms-txt.test.ts +0 -123
  484. package/templates/apps/web/__tests__/routes/robots-txt.test.ts +0 -52
  485. package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +0 -78
  486. package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +0 -203
  487. package/templates/apps/web/__tests__/section-routes.test.tsx +0 -693
  488. package/templates/apps/web/__tests__/security-headers.test.ts +0 -201
  489. package/templates/apps/web/__tests__/sentry-config.test.ts +0 -116
  490. package/templates/apps/web/__tests__/sitemap-locales.test.ts +0 -415
  491. package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +0 -844
  492. package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +0 -120
  493. package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +0 -202
  494. package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +0 -152
  495. package/templates/apps/web/content/en/blog/index.mdx +0 -15
  496. package/templates/apps/web/content/en/blog/launching-ailk.mdx +0 -20
  497. package/templates/apps/web/content/en/docs/getting-started.mdx +0 -30
  498. package/templates/content/.gitkeep +0 -0
  499. package/templates/content/CLAUDE.md +0 -85
  500. package/templates/content/ar/HomePage/home.mdx +0 -123
  501. package/templates/content/ar/_site.mdx +0 -35
  502. package/templates/content/de/_site.mdx +0 -29
  503. package/templates/content/en/.gitkeep +0 -0
  504. package/templates/content/en/AboutPage/about.schema.json +0 -82
  505. package/templates/content/en/BookCall/book-call.mdx +0 -19
  506. package/templates/content/en/BookCall/book-call.schema.json +0 -121
  507. package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +0 -20
  508. package/templates/content/en/CareersList/careers.mdx +0 -24
  509. package/templates/content/en/CaseStudiesList/case-studies.mdx +0 -22
  510. package/templates/content/en/CaseStudiesList/showcase.mdx +0 -8
  511. package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +0 -32
  512. package/templates/content/en/CaseStudy/schema-builder-migration.mdx +0 -41
  513. package/templates/content/en/ClientsList/clients.mdx +0 -38
  514. package/templates/content/en/ContactPage/contact.schema.json +0 -100
  515. package/templates/content/en/ContactPoint/primary.mdx +0 -14
  516. package/templates/content/en/Course/aeo-fundamentals.mdx +0 -37
  517. package/templates/content/en/Course/schema-builder-workshop.mdx +0 -62
  518. package/templates/content/en/CoursesList/courses.mdx +0 -22
  519. package/templates/content/en/DownloadsList/downloads.mdx +0 -22
  520. package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +0 -39
  521. package/templates/content/en/Event/ailk-launch-day-2026.mdx +0 -35
  522. package/templates/content/en/EventsList/events.mdx +0 -26
  523. package/templates/content/en/FAQPage/faq.schema.json +0 -231
  524. package/templates/content/en/GlossaryList/glossary.mdx +0 -19
  525. package/templates/content/en/GlossaryList/glossary.schema.json +0 -300
  526. package/templates/content/en/HomePage/home.schema.json +0 -88
  527. package/templates/content/en/HomePage/launch-service.mdx +0 -475
  528. package/templates/content/en/HomePage/launch-service.schema.json +0 -138
  529. package/templates/content/en/HowTo/add-mdx-content.mdx +0 -38
  530. package/templates/content/en/HowTo/configure-aeo-scoring.mdx +0 -35
  531. package/templates/content/en/HowTo/set-up-json-ld.mdx +0 -36
  532. package/templates/content/en/Integration/github-actions.mdx +0 -31
  533. package/templates/content/en/Integration/openai-api.mdx +0 -26
  534. package/templates/content/en/Integration/vercel.mdx +0 -32
  535. package/templates/content/en/IntegrationsList/integrations.mdx +0 -22
  536. package/templates/content/en/JobPosting/content-strategist.mdx +0 -46
  537. package/templates/content/en/JobPosting/developer-advocate.mdx +0 -47
  538. package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +0 -57
  539. package/templates/content/en/LandingPage/aeo-audit.mdx +0 -19
  540. package/templates/content/en/LandingPage/aeo-audit.schema.json +0 -92
  541. package/templates/content/en/LandingPage/agency-waitlist.mdx +0 -340
  542. package/templates/content/en/LandingPage/agency-waitlist.schema.json +0 -152
  543. package/templates/content/en/LandingPage/developers.mdx +0 -19
  544. package/templates/content/en/LandingPage/developers.schema.json +0 -109
  545. package/templates/content/en/LandingPage/for-agencies.mdx +0 -390
  546. package/templates/content/en/LandingPage/for-agencies.schema.json +0 -107
  547. package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +0 -417
  548. package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +0 -105
  549. package/templates/content/en/LandingPage/for-businesses.mdx +0 -19
  550. package/templates/content/en/LandingPage/for-businesses.schema.json +0 -96
  551. package/templates/content/en/LandingPage/platform.mdx +0 -369
  552. package/templates/content/en/LandingPage/platform.schema.json +0 -166
  553. package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +0 -19
  554. package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +0 -102
  555. package/templates/content/en/LandingPage/vs-wordpress.mdx +0 -19
  556. package/templates/content/en/LandingPage/vs-wordpress.schema.json +0 -53
  557. package/templates/content/en/NewsletterSignup/newsletter.mdx +0 -236
  558. package/templates/content/en/NewsletterSignup/newsletter.schema.json +0 -91
  559. package/templates/content/en/Organization/site-owner.mdx +0 -16
  560. package/templates/content/en/Partner/prisma.mdx +0 -31
  561. package/templates/content/en/Partner/supabase.mdx +0 -30
  562. package/templates/content/en/Partner/vercel.mdx +0 -28
  563. package/templates/content/en/PartnersList/partners.mdx +0 -22
  564. package/templates/content/en/Person/founder.mdx +0 -18
  565. package/templates/content/en/Pricing/pricing.mdx +0 -538
  566. package/templates/content/en/Pricing/pricing.schema.json +0 -209
  567. package/templates/content/en/PrivacyPage/privacy.mdx +0 -165
  568. package/templates/content/en/PrivacyPage/privacy.schema.json +0 -64
  569. package/templates/content/en/Product/ailk.mdx +0 -19
  570. package/templates/content/en/Product/ailk.schema.json +0 -94
  571. package/templates/content/en/Product/api-launch-kit.mdx +0 -26
  572. package/templates/content/en/Product/launch-kit.mdx +0 -28
  573. package/templates/content/en/ProductsList/products.mdx +0 -26
  574. package/templates/content/en/Project/ailk-phase-2.mdx +0 -54
  575. package/templates/content/en/ProjectsList/projects.mdx +0 -25
  576. package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +0 -27
  577. package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +0 -28
  578. package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +0 -39
  579. package/templates/content/en/ResourcesHub/resources.mdx +0 -22
  580. package/templates/content/en/Service/consulting.mdx +0 -22
  581. package/templates/content/en/Service/implementation.mdx +0 -30
  582. package/templates/content/en/Service/launch-service.mdx +0 -232
  583. package/templates/content/en/Service/launch.mdx +0 -228
  584. package/templates/content/en/ServicesList/services.mdx +0 -84
  585. package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +0 -44
  586. package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +0 -47
  587. package/templates/content/en/SoftwareProduct/ailk-schema.mdx +0 -49
  588. package/templates/content/en/TeamList/team.mdx +0 -63
  589. package/templates/content/en/TeamMember/founder.mdx +0 -72
  590. package/templates/content/en/TermsPage/terms.mdx +0 -19
  591. package/templates/content/en/TermsPage/terms.schema.json +0 -99
  592. package/templates/content/en/TestimonialsList/testimonials.mdx +0 -44
  593. package/templates/content/en/UseCasesList/use-cases.mdx +0 -30
  594. package/templates/content/en/Whitepaper/ax-first-product-development.mdx +0 -27
  595. package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +0 -27
  596. package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +0 -31
  597. package/templates/content/en-XA/AboutPage/about.mdx +0 -8
  598. package/templates/content/en-XA/FAQPage/faq.mdx +0 -8
  599. package/templates/content/en-XA/HomePage/home.mdx +0 -8
  600. package/templates/content/es/_site.mdx +0 -30
  601. package/templates/content/fr/_site.mdx +0 -30
  602. package/templates/content/ja/_site.mdx +0 -30
  603. package/templates/content/pt-BR/AboutPage/about.mdx +0 -8
  604. package/templates/content/pt-BR/FAQPage/faq.mdx +0 -8
  605. package/templates/content/pt-BR/HomePage/home.mdx +0 -23
@@ -0,0 +1,742 @@
1
+ /**
2
+ * @file drafts.test.ts
3
+ *
4
+ * THE ACCEPTANCE GATE for the draft lifecycle (#5036 D1/D7) — the three
5
+ * anonymous routes that replaced the single `POST /v1/project-listings`
6
+ * create: `start`, `patch` and `submit`.
7
+ *
8
+ * Real routes and real services, mocked `lib/prisma.js` backed by a small
9
+ * in-memory row store, so a patch genuinely WRITES and a later read genuinely
10
+ * READS BACK. A `jest.fn()` returning a canned object could not tell a
11
+ * round-trip from a no-op, and the round-trip is the thing under test.
12
+ * `routeLead` runs for real against a per-test temp path
13
+ * (`setupLeadFileEnv`), matching the create test's discipline before it;
14
+ * `@working-theory/email` and `@working-theory/license` stay mocked (external
15
+ * package boundaries — see docs/lessons/project-listings/02).
16
+ *
17
+ * `slack-notify` is NOT mocked, deliberately: `AILK_SLACK_BOT_TOKEN` is unset
18
+ * here, so `postListingApplication` takes its own documented no-op branch
19
+ * (`slack-skipped`) and never reaches `fetch`. Stubbing the module would be
20
+ * mocking internal code to avoid a boundary that is already closed.
21
+ *
22
+ * Four of these fail QUIETLY if they regress, which is why each is here:
23
+ * - a draft created from name and email alone must PERSIST, and a
24
+ * single-field patch must round-trip — otherwise the lifecycle silently
25
+ * stores nothing and the founder loses ten steps of work;
26
+ * - an incomplete draft at submit must be refused with the missing fields
27
+ * NAMED, not silently accepted into `requested`;
28
+ * - a resume token must grant ONE listing and nothing else, since it is the
29
+ * only credential on an anonymous surface;
30
+ * - drafts must COUNT toward the two-listing limit, or the limit is evaded
31
+ * by never submitting.
32
+ */
33
+ import { createHash } from "node:crypto";
34
+
35
+ import Fastify from "fastify";
36
+ import type { FastifyInstance } from "fastify";
37
+
38
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock
39
+ jest.mock("../../../lib/prisma.js");
40
+
41
+ jest.mock("@working-theory/email", () => ({
42
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
43
+ }));
44
+
45
+ jest.mock("@working-theory/license", () => ({
46
+ checkLicense: jest.fn().mockResolvedValue({
47
+ tier: "free",
48
+ plan: null,
49
+ features: [],
50
+ expiresAt: null,
51
+ revoked: false,
52
+ }),
53
+ }));
54
+
55
+ import { prisma } from "../../../lib/prisma.js";
56
+ import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
57
+ import { patchProjectListingDraftRoute } from "../patch-draft.js";
58
+ import { startProjectListingRoute } from "../start.js";
59
+ import { submitProjectListingRoute } from "../submit.js";
60
+
61
+ // ─── Fixtures ───────────────────────────────────────────────────────────────
62
+
63
+ const opt = (value: string, label: string) => ({ value, label });
64
+
65
+ const VALID_QUESTIONS = {
66
+ q2: {
67
+ prompt: "Why are you here?",
68
+ options: [opt("scale", "Scaling my team"), opt("just-curious", "Just curious")],
69
+ },
70
+ q3: { prompt: "Stage?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
71
+ q4: { prompt: "Team size?", options: [opt("a", "A"), opt("b", "B"), opt("c", "C")] },
72
+ q5: {
73
+ prompt: "Urgency?",
74
+ options: [opt("urgent", "Now"), opt("planning", "Soon"), opt("exploring", "Someday")],
75
+ },
76
+ q6: {
77
+ prompt: "Pain?",
78
+ options: [
79
+ opt("low", "Low"),
80
+ opt("medium", "Medium"),
81
+ opt("high", "High"),
82
+ opt("critical", "Critical"),
83
+ ],
84
+ },
85
+ q7: { prompt: "Anything else?" },
86
+ extras: [],
87
+ };
88
+
89
+ /**
90
+ * The site is named by its PUBLIC KEY now (#5048 D2) — `siteId` never travels
91
+ * in a request body. `installStore` maps this key to `SITE_ID` the way a
92
+ * `SiteListingConfig.publicKey` row does.
93
+ */
94
+ const SITE_ID = "site-1";
95
+ const SITE_KEY = "site-1-public-key";
96
+
97
+ const START_PAYLOAD = {
98
+ siteKey: SITE_KEY,
99
+ founderName: "Ada Founder",
100
+ founderEmail: "ada@example.com",
101
+ consent: true,
102
+ };
103
+
104
+ /** Everything the ten steps after step one collect. */
105
+ const COMPLETE_APPLICATION = {
106
+ name: "My Project",
107
+ headline: "Ten words or fewer for the tile title here",
108
+ oneLiner: "A short sentence describing what the project does for its people.",
109
+ problem: "The problem is real and this describes it.",
110
+ product: "wireframes",
111
+ commitment: "part-time",
112
+ teamComposition: "co-founders",
113
+ faqs: [
114
+ { question: "Q one?", answer: "A one." },
115
+ { question: "Q two?", answer: "A two." },
116
+ { question: "Q three?", answer: "A three." },
117
+ ],
118
+ address: "1 Founder Way, Springfield",
119
+ questions: VALID_QUESTIONS,
120
+ };
121
+
122
+ // ─── The in-memory row store the Prisma mock is backed by ───────────────────
123
+
124
+ type Row = Record<string, unknown>;
125
+
126
+ let rows: Row[];
127
+ let nextId: number;
128
+
129
+ function matches(row: Row, where: Record<string, unknown>): boolean {
130
+ return Object.entries(where).every(([key, value]) => {
131
+ if (key === "OR") {
132
+ return (value as Record<string, unknown>[]).some((clause) => matches(row, clause));
133
+ }
134
+ if (value !== null && typeof value === "object" && !(value instanceof Date)) {
135
+ const clause = value as Record<string, unknown>;
136
+ if ("in" in clause) return (clause["in"] as unknown[]).includes(row[key]);
137
+ if ("not" in clause) return row[key] !== clause["not"];
138
+ if ("gt" in clause) return (row[key] as Date) > (clause["gt"] as Date);
139
+ if ("startsWith" in clause) {
140
+ return typeof row[key] === "string" && (row[key] as string).startsWith(clause["startsWith"] as string);
141
+ }
142
+ }
143
+ return row[key] === value;
144
+ });
145
+ }
146
+
147
+ function installStore(): void {
148
+ rows = [];
149
+ nextId = 1;
150
+
151
+ (prisma.lead.create as jest.Mock).mockImplementation(() =>
152
+ Promise.resolve({ id: `lead_00000000-0000-7000-8000-00000000000${nextId}` }),
153
+ );
154
+ // One delegate, two lookups (#5048 D2): `where.publicKey` is the site-key
155
+ // resolution (`siteIdForPublicKey`), `where.siteId` the config read
156
+ // (`listingConfigFor`). No config row for the latter, so the platform
157
+ // defaults apply — the behaviour every test below was written against.
158
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
159
+ ({ where }: { where: { siteId?: string; publicKey?: string } }) => {
160
+ if (where.publicKey !== undefined) {
161
+ return Promise.resolve(where.publicKey === SITE_KEY ? { siteId: SITE_ID } : null);
162
+ }
163
+ return Promise.resolve(null);
164
+ },
165
+ );
166
+
167
+ (prisma.projectListing.create as jest.Mock).mockImplementation(
168
+ ({ data }: { data: Row }) => {
169
+ const now = new Date();
170
+ const row: Row = {
171
+ id: `prjl_00000000-0000-7000-8000-00000000000${nextId++}`,
172
+ slug: null,
173
+ name: null,
174
+ oneLiner: null,
175
+ problem: null,
176
+ url: null,
177
+ product: null,
178
+ commitment: null,
179
+ teamComposition: null,
180
+ faqs: null,
181
+ anythingElse: null,
182
+ address: null,
183
+ questions: null,
184
+ founderCompany: null,
185
+ leadId: null,
186
+ headline: null,
187
+ logoMarkUrl: null,
188
+ logoWordmarkUrl: null,
189
+ featured: false,
190
+ decidedAt: null,
191
+ decidedBy: null,
192
+ issueUrl: null,
193
+ experimentKey: null,
194
+ windowEndsAt: null,
195
+ createdAt: now,
196
+ updatedAt: now,
197
+ ...data,
198
+ };
199
+ rows.push(row);
200
+ return Promise.resolve(row);
201
+ },
202
+ );
203
+
204
+ (prisma.projectListing.findFirst as jest.Mock).mockImplementation(
205
+ ({ where }: { where: Record<string, unknown> }) =>
206
+ Promise.resolve(rows.find((row) => matches(row, where)) ?? null),
207
+ );
208
+
209
+ (prisma.projectListing.findMany as jest.Mock).mockImplementation(
210
+ ({ where }: { where: Record<string, unknown> }) =>
211
+ Promise.resolve(rows.filter((row) => matches(row, where))),
212
+ );
213
+
214
+ (prisma.projectListing.update as jest.Mock).mockImplementation(
215
+ ({ where, data }: { where: Record<string, unknown>; data: Row }) => {
216
+ const row = rows.find((r) => matches(r, where));
217
+ Object.assign(row!, data, { updatedAt: new Date() });
218
+ return Promise.resolve(row);
219
+ },
220
+ );
221
+
222
+ (prisma.projectListing.updateMany as jest.Mock).mockImplementation(
223
+ ({ where, data }: { where: Record<string, unknown>; data: Row }) => {
224
+ const matched = rows.filter((r) => matches(r, where));
225
+ matched.forEach((r) => Object.assign(r, data, { updatedAt: new Date() }));
226
+ return Promise.resolve({ count: matched.length });
227
+ },
228
+ );
229
+ }
230
+
231
+ // ─── Helpers ────────────────────────────────────────────────────────────────
232
+
233
+ const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
234
+
235
+ const bearer = (token: string) => ({ authorization: `Bearer ${token}` });
236
+
237
+ async function buildApp(): Promise<FastifyInstance> {
238
+ const app = Fastify();
239
+ await app.register(startProjectListingRoute);
240
+ await app.register(patchProjectListingDraftRoute);
241
+ await app.register(submitProjectListingRoute);
242
+ await app.ready();
243
+ return app;
244
+ }
245
+
246
+ async function startDraft(
247
+ app: FastifyInstance,
248
+ overrides: Record<string, unknown> = {},
249
+ ): Promise<{ id: string; resumeToken: string }> {
250
+ const res = await app.inject({
251
+ method: "POST",
252
+ url: "/v1/project-listings",
253
+ payload: { ...START_PAYLOAD, ...overrides },
254
+ });
255
+ expect(res.statusCode).toBe(201);
256
+ return (res.json() as { ok: true; data: { id: string; resumeToken: string } }).data;
257
+ }
258
+
259
+ // ─── Tests ──────────────────────────────────────────────────────────────────
260
+
261
+ describe("the draft lifecycle (#5036 D1)", () => {
262
+ setupLeadFileEnv("project-listing-drafts");
263
+ let app: FastifyInstance;
264
+
265
+ beforeEach(async () => {
266
+ jest.clearAllMocks();
267
+ installStore();
268
+ app = await buildApp();
269
+ });
270
+
271
+ afterEach(async () => {
272
+ await app.close();
273
+ });
274
+
275
+ // ── start ──
276
+
277
+ it("start: a draft created from name and email alone PERSISTS", async () => {
278
+ const { id, resumeToken } = await startDraft(app);
279
+
280
+ expect(id).toMatch(/^prjl_/);
281
+ expect(resumeToken.length).toBeGreaterThan(0);
282
+
283
+ // The row exists, at `draft`, carrying nothing but the identity — every
284
+ // application field still null. This is the "nullable at rest" invariant
285
+ // seen from the outside: without it the create itself would have failed.
286
+ const row = rows[0]!;
287
+ expect(row["status"]).toBe("draft");
288
+ expect(row["founderEmail"]).toBe("ada@example.com");
289
+ expect(row["name"]).toBeNull();
290
+ expect(row["oneLiner"]).toBeNull();
291
+ expect(row["product"]).toBeNull();
292
+ expect(row["questions"]).toBeNull();
293
+ expect(row["slug"]).toBeNull();
294
+
295
+ // NO Lead at step one. The contact is already captured on this row, and
296
+ // `routeLead` mails the operator — which on an anonymous route would let a
297
+ // stranger fill the operator's inbox by opening drafts they never finish
298
+ // (PR #5042 security review, finding 4). The Lead waits for `submit`.
299
+ expect(prisma.lead.create).not.toHaveBeenCalled();
300
+ expect(row["leadId"]).toBeNull();
301
+ });
302
+
303
+ it("start: lowercases the founder's email, so the limit cannot be evaded by case", async () => {
304
+ // Postgres text equality is case-sensitive, so without this the
305
+ // concurrent-listing limit is evaded by holding down shift. (Surrounding
306
+ // whitespace needs no test: `z.string().email()` refuses it outright.)
307
+ await startDraft(app, { founderEmail: "Ada@Example.COM" });
308
+ expect(rows[0]!["founderEmail"]).toBe("ada@example.com");
309
+ });
310
+
311
+ it("start: stores a DIGEST of the resume token, never the token", async () => {
312
+ const { resumeToken } = await startDraft(app);
313
+ const stored = rows[0]!["resumeTokenHash"];
314
+
315
+ expect(stored).not.toBe(resumeToken);
316
+ expect(stored).toBe(sha256(resumeToken));
317
+ // 256 bits of CSPRNG output, base64url-encoded.
318
+ expect(Buffer.from(resumeToken, "base64url")).toHaveLength(32);
319
+ });
320
+
321
+ it("start: two drafts get different tokens", async () => {
322
+ const first = await startDraft(app);
323
+ const second = await startDraft(app, { founderEmail: "grace@example.com" });
324
+ expect(first.resumeToken).not.toBe(second.resumeToken);
325
+ });
326
+
327
+ it("start: refuses a payload that declines consent", async () => {
328
+ const res = await app.inject({
329
+ method: "POST",
330
+ url: "/v1/project-listings",
331
+ payload: { ...START_PAYLOAD, consent: false },
332
+ });
333
+ expect(res.statusCode).toBe(400);
334
+ expect((res.json() as { ok: boolean }).ok).toBe(false);
335
+ expect(rows).toHaveLength(0);
336
+ });
337
+
338
+ // ── patch ──
339
+
340
+ it("patch: a SINGLE-field patch round-trips", async () => {
341
+ const { id, resumeToken } = await startDraft(app);
342
+
343
+ const res = await app.inject({
344
+ method: "PATCH",
345
+ url: `/v1/project-listings/drafts/${id}`,
346
+ headers: bearer(resumeToken),
347
+ payload: { oneLiner: "Just the one field, saved on its own." },
348
+ });
349
+
350
+ expect(res.statusCode).toBe(200);
351
+ const body = res.json() as { ok: boolean; data: Record<string, unknown> };
352
+ expect(body.ok).toBe(true);
353
+ expect(body.data["oneLiner"]).toBe("Just the one field, saved on its own.");
354
+ // Still a draft, still incomplete — `patch` asserts no completeness.
355
+ expect(body.data["status"]).toBe("draft");
356
+ expect(body.data["name"]).toBeNull();
357
+ });
358
+
359
+ it("patch: a later patch MERGES rather than replacing", async () => {
360
+ const { id, resumeToken } = await startDraft(app);
361
+
362
+ await app.inject({
363
+ method: "PATCH",
364
+ url: `/v1/project-listings/drafts/${id}`,
365
+ headers: bearer(resumeToken),
366
+ payload: { name: "Step three" },
367
+ });
368
+ const res = await app.inject({
369
+ method: "PATCH",
370
+ url: `/v1/project-listings/drafts/${id}`,
371
+ headers: bearer(resumeToken),
372
+ payload: { address: "1 Founder Way" },
373
+ });
374
+
375
+ const body = res.json() as { data: Record<string, unknown> };
376
+ // The earlier step survives the later one — the failure this guards is a
377
+ // patch that blanks the nine fields the founder already filled in.
378
+ expect(body.data["name"]).toBe("Step three");
379
+ expect(body.data["address"]).toBe("1 Founder Way");
380
+ });
381
+
382
+ it("patch: refuses an empty body", async () => {
383
+ const { id, resumeToken } = await startDraft(app);
384
+ const res = await app.inject({
385
+ method: "PATCH",
386
+ url: `/v1/project-listings/drafts/${id}`,
387
+ headers: bearer(resumeToken),
388
+ payload: {},
389
+ });
390
+ expect(res.statusCode).toBe(400);
391
+ });
392
+
393
+ it("patch: 401s with no resume token at all", async () => {
394
+ const { id } = await startDraft(app);
395
+ const res = await app.inject({
396
+ method: "PATCH",
397
+ url: `/v1/project-listings/drafts/${id}`,
398
+ payload: { name: "No credential" },
399
+ });
400
+ expect(res.statusCode).toBe(401);
401
+ });
402
+
403
+ // ── the token's binding ──
404
+
405
+ it("a resume token grants ONE listing and no other", async () => {
406
+ const first = await startDraft(app);
407
+ const second = await startDraft(app, { founderEmail: "grace@example.com" });
408
+
409
+ // The first founder's token, presented against the second's listing.
410
+ const res = await app.inject({
411
+ method: "PATCH",
412
+ url: `/v1/project-listings/drafts/${second.id}`,
413
+ headers: bearer(first.resumeToken),
414
+ payload: { name: "Not mine to write" },
415
+ });
416
+
417
+ expect(res.statusCode).toBe(404);
418
+ // And nothing was written to the row it was aimed at.
419
+ const target = rows.find((r) => r["id"] === second.id)!;
420
+ expect(target["name"]).toBeNull();
421
+ });
422
+
423
+ it("a wrong token is a 404, indistinguishable from an unknown draft", async () => {
424
+ const { id } = await startDraft(app);
425
+
426
+ const wrongToken = await app.inject({
427
+ method: "PATCH",
428
+ url: `/v1/project-listings/drafts/${id}`,
429
+ headers: bearer("not-the-token"),
430
+ payload: { name: "x" },
431
+ });
432
+ const unknownDraft = await app.inject({
433
+ method: "PATCH",
434
+ url: "/v1/project-listings/drafts/prjl_00000000-0000-7000-8000-0000000000ff",
435
+ headers: bearer("not-the-token"),
436
+ payload: { name: "x" },
437
+ });
438
+
439
+ expect(wrongToken.statusCode).toBe(404);
440
+ expect(unknownDraft.statusCode).toBe(404);
441
+ expect(wrongToken.json()).toEqual(unknownDraft.json());
442
+ });
443
+
444
+ // ── submit ──
445
+
446
+ it("submit: an INCOMPLETE draft is refused with the missing fields NAMED", async () => {
447
+ const { id, resumeToken } = await startDraft(app);
448
+ // Two of the eleven required fields filled in; nine still missing.
449
+ await app.inject({
450
+ method: "PATCH",
451
+ url: `/v1/project-listings/drafts/${id}`,
452
+ headers: bearer(resumeToken),
453
+ payload: { name: "My Project", address: "1 Founder Way" },
454
+ });
455
+
456
+ const res = await app.inject({
457
+ method: "POST",
458
+ url: `/v1/project-listings/drafts/${id}/submit`,
459
+ headers: bearer(resumeToken),
460
+ });
461
+
462
+ expect(res.statusCode).toBe(422);
463
+ const body = res.json() as { error: { code: string; missingFields: string[] } };
464
+ expect(body.error.code).toBe("incomplete_application");
465
+ // NAMED, so the form can send the founder to the step that owns each one.
466
+ expect(body.error.missingFields).toEqual(
467
+ expect.arrayContaining([
468
+ "commitment",
469
+ "faqs",
470
+ "headline",
471
+ "oneLiner",
472
+ "problem",
473
+ "product",
474
+ "questions",
475
+ "teamComposition",
476
+ ]),
477
+ );
478
+ // The two fields that WERE filled in are not reported missing.
479
+ expect(body.error.missingFields).not.toContain("name");
480
+ expect(body.error.missingFields).not.toContain("address");
481
+ // And the row did not move.
482
+ expect(rows.find((r) => r["id"] === id)!["status"]).toBe("draft");
483
+ });
484
+
485
+ it("submit: a COMPLETE draft transitions to `requested` and returns the derived slug", async () => {
486
+ const { id, resumeToken } = await startDraft(app);
487
+ await app.inject({
488
+ method: "PATCH",
489
+ url: `/v1/project-listings/drafts/${id}`,
490
+ headers: bearer(resumeToken),
491
+ payload: COMPLETE_APPLICATION,
492
+ });
493
+
494
+ const res = await app.inject({
495
+ method: "POST",
496
+ url: `/v1/project-listings/drafts/${id}/submit`,
497
+ headers: bearer(resumeToken),
498
+ });
499
+
500
+ expect(res.statusCode).toBe(200);
501
+ expect(res.json()).toEqual({ ok: true, data: { id, slug: "my-project" } });
502
+
503
+ const row = rows.find((r) => r["id"] === id)!;
504
+ expect(row["status"]).toBe("requested");
505
+ // The Lead is written HERE, once, for a completed application.
506
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
507
+ const lead = (prisma.lead.create as jest.Mock).mock.calls[0][0];
508
+ expect(lead.data.source).toBe("projects-apply");
509
+ expect(lead.data.email).toBe("ada@example.com");
510
+ expect(row["leadId"]).not.toBeNull();
511
+ expect(row["slug"]).toBe("my-project");
512
+ // The token is spent: a leak after submission grants nothing.
513
+ expect(row["resumeTokenHash"]).toBeNull();
514
+ });
515
+
516
+ it("submit: a spent token cannot patch or re-submit the listing", async () => {
517
+ const { id, resumeToken } = await startDraft(app);
518
+ await app.inject({
519
+ method: "PATCH",
520
+ url: `/v1/project-listings/drafts/${id}`,
521
+ headers: bearer(resumeToken),
522
+ payload: COMPLETE_APPLICATION,
523
+ });
524
+ await app.inject({
525
+ method: "POST",
526
+ url: `/v1/project-listings/drafts/${id}/submit`,
527
+ headers: bearer(resumeToken),
528
+ });
529
+
530
+ const patchAgain = await app.inject({
531
+ method: "PATCH",
532
+ url: `/v1/project-listings/drafts/${id}`,
533
+ headers: bearer(resumeToken),
534
+ payload: { name: "Renamed after the fact" },
535
+ });
536
+ const submitAgain = await app.inject({
537
+ method: "POST",
538
+ url: `/v1/project-listings/drafts/${id}/submit`,
539
+ headers: bearer(resumeToken),
540
+ });
541
+
542
+ expect(patchAgain.statusCode).toBe(404);
543
+ expect(submitAgain.statusCode).toBe(404);
544
+ expect(rows.find((r) => r["id"] === id)!["name"]).toBe("My Project");
545
+ });
546
+
547
+ it("submit: 401s with no resume token at all", async () => {
548
+ const { id } = await startDraft(app);
549
+ const res = await app.inject({
550
+ method: "POST",
551
+ url: `/v1/project-listings/drafts/${id}/submit`,
552
+ });
553
+ expect(res.statusCode).toBe(401);
554
+ });
555
+
556
+ // ── the concurrent-listing limit (#5036 D7) ──
557
+
558
+ it("a third concurrent listing is refused at submit — and one of the two is a DRAFT", async () => {
559
+ // One already ACTIVE listing for this founder…
560
+ const active = await startDraft(app);
561
+ Object.assign(rows.find((r) => r["id"] === active.id)!, {
562
+ status: "active",
563
+ slug: "already-live",
564
+ windowEndsAt: new Date(Date.now() + 86_400_000),
565
+ });
566
+
567
+ // …and one still a DRAFT. If drafts did not count, the limit would be
568
+ // evaded by simply never submitting — which is the state a ten-step form
569
+ // leaves a founder in by default.
570
+ await startDraft(app);
571
+
572
+ // The third.
573
+ const third = await startDraft(app);
574
+ await app.inject({
575
+ method: "PATCH",
576
+ url: `/v1/project-listings/drafts/${third.id}`,
577
+ headers: bearer(third.resumeToken),
578
+ payload: COMPLETE_APPLICATION,
579
+ });
580
+ const res = await app.inject({
581
+ method: "POST",
582
+ url: `/v1/project-listings/drafts/${third.id}/submit`,
583
+ headers: bearer(third.resumeToken),
584
+ });
585
+
586
+ expect(res.statusCode).toBe(409);
587
+ expect((res.json() as { error: { code: string } }).error.code).toBe("listing_limit_reached");
588
+ expect(rows.find((r) => r["id"] === third.id)!["status"]).toBe("draft");
589
+ });
590
+
591
+ it("a long-abandoned draft stops holding a slot", async () => {
592
+ // Drafts count — that is D7, and it is what stops the limit being evaded by
593
+ // never submitting. But `founderEmail` is unverified here, so a draft that
594
+ // counted FOREVER would let two anonymous requests lock a named founder out
595
+ // permanently, with no operator remedy (PR #5042 security review, finding
596
+ // 1). The window is what makes that lockout expire.
597
+ for (let i = 0; i < 2; i += 1) {
598
+ const stale = await startDraft(app);
599
+ Object.assign(rows.find((r) => r["id"] === stale.id)!, {
600
+ createdAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000),
601
+ });
602
+ }
603
+
604
+ const third = await startDraft(app);
605
+ await app.inject({
606
+ method: "PATCH",
607
+ url: `/v1/project-listings/drafts/${third.id}`,
608
+ headers: bearer(third.resumeToken),
609
+ payload: COMPLETE_APPLICATION,
610
+ });
611
+ const res = await app.inject({
612
+ method: "POST",
613
+ url: `/v1/project-listings/drafts/${third.id}/submit`,
614
+ headers: bearer(third.resumeToken),
615
+ });
616
+
617
+ expect(res.statusCode).toBe(200);
618
+ });
619
+
620
+ it("a FRESH draft still holds a slot", async () => {
621
+ // The other half of the window: two drafts created today do bind, so the
622
+ // limit is not evaded by simply not pressing submit.
623
+ await startDraft(app);
624
+ await startDraft(app);
625
+
626
+ const third = await startDraft(app);
627
+ await app.inject({
628
+ method: "PATCH",
629
+ url: `/v1/project-listings/drafts/${third.id}`,
630
+ headers: bearer(third.resumeToken),
631
+ payload: COMPLETE_APPLICATION,
632
+ });
633
+ const res = await app.inject({
634
+ method: "POST",
635
+ url: `/v1/project-listings/drafts/${third.id}/submit`,
636
+ headers: bearer(third.resumeToken),
637
+ });
638
+
639
+ expect(res.statusCode).toBe(409);
640
+ });
641
+
642
+ it("a TERMINAL listing does not count toward the limit", async () => {
643
+ // Two listings, but both terminal: one rejected, one whose window elapsed
644
+ // (which reads `closed`, computed, with no job having run).
645
+ const rejected = await startDraft(app);
646
+ Object.assign(rows.find((r) => r["id"] === rejected.id)!, {
647
+ status: "rejected",
648
+ slug: "turned-down",
649
+ });
650
+ const expired = await startDraft(app);
651
+ Object.assign(rows.find((r) => r["id"] === expired.id)!, {
652
+ status: "active",
653
+ slug: "ran-its-course",
654
+ windowEndsAt: new Date(Date.now() - 86_400_000),
655
+ });
656
+
657
+ const third = await startDraft(app);
658
+ await app.inject({
659
+ method: "PATCH",
660
+ url: `/v1/project-listings/drafts/${third.id}`,
661
+ headers: bearer(third.resumeToken),
662
+ payload: COMPLETE_APPLICATION,
663
+ });
664
+ const res = await app.inject({
665
+ method: "POST",
666
+ url: `/v1/project-listings/drafts/${third.id}/submit`,
667
+ headers: bearer(third.resumeToken),
668
+ });
669
+
670
+ expect(res.statusCode).toBe(200);
671
+ });
672
+
673
+ it("the limit is scoped to one founder — another founder's listings do not count", async () => {
674
+ for (const email of ["grace@example.com", "alan@example.com"]) {
675
+ const other = await startDraft(app, { founderEmail: email });
676
+ Object.assign(rows.find((r) => r["id"] === other.id)!, {
677
+ status: "active",
678
+ slug: `live-${email.split("@")[0]}`,
679
+ windowEndsAt: new Date(Date.now() + 86_400_000),
680
+ });
681
+ }
682
+
683
+ const mine = await startDraft(app);
684
+ await app.inject({
685
+ method: "PATCH",
686
+ url: `/v1/project-listings/drafts/${mine.id}`,
687
+ headers: bearer(mine.resumeToken),
688
+ payload: COMPLETE_APPLICATION,
689
+ });
690
+ const res = await app.inject({
691
+ method: "POST",
692
+ url: `/v1/project-listings/drafts/${mine.id}/submit`,
693
+ headers: bearer(mine.resumeToken),
694
+ });
695
+
696
+ expect(res.statusCode).toBe(200);
697
+ });
698
+
699
+ // ── logging ──
700
+
701
+ it("no founder PII and no resume token reaches the route's own log line", async () => {
702
+ // Scoped to the log statements THIS route makes. Fastify's built-in
703
+ // request/response logging serializes the raw request (body included) and
704
+ // `inject`'s own harness stashes the response payload on it — neither is
705
+ // this route's doing, and neither is what the PII boundary is about. What
706
+ // is this route's doing is the one `request.log.info` call below.
707
+ const calls: Record<string, unknown>[] = [];
708
+ const logging = Fastify({
709
+ logger: {
710
+ level: "info",
711
+ hooks: {
712
+ logMethod(args: unknown[], method: (...a: never[]) => void) {
713
+ if (typeof args[1] === "string" && args[1] === "project listing draft started") {
714
+ calls.push(args[0] as Record<string, unknown>);
715
+ }
716
+ method.apply(this, args as never[]);
717
+ },
718
+ },
719
+ },
720
+ disableRequestLogging: true,
721
+ });
722
+ await logging.register(startProjectListingRoute);
723
+ await logging.ready();
724
+
725
+ const res = await logging.inject({
726
+ method: "POST",
727
+ url: "/v1/project-listings",
728
+ payload: START_PAYLOAD,
729
+ });
730
+ const { resumeToken } = (res.json() as { data: { resumeToken: string } }).data;
731
+
732
+ expect(calls).toHaveLength(1);
733
+ const serialized = JSON.stringify(calls[0]);
734
+ expect(serialized).not.toContain("ada@example.com");
735
+ expect(serialized).not.toContain("Ada Founder");
736
+ expect(serialized).not.toContain(resumeToken);
737
+ // Routing metadata only.
738
+ expect(Object.keys(calls[0]!).sort()).toEqual(["ip", "listingId", "siteId"]);
739
+
740
+ await logging.close();
741
+ });
742
+ });