create-ailk 0.4.0 → 0.6.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.
- package/README.md +55 -8
- package/component-catalog.json +12248 -0
- package/component-catalog.md +193 -68
- package/dist/cli.js +29 -1
- package/dist/derive-architecture.js +5 -2
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/extract-module-bundle.d.ts +50 -2
- package/dist/lib/extract-module-bundle.js +223 -15
- package/dist/lib/fetch-guard.d.ts +28 -0
- package/dist/lib/fetch-guard.js +101 -0
- package/dist/lib/fetch-module.js +4 -0
- package/dist/lib/fetch-template.js +4 -0
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/module-architecture.d.ts +60 -8
- package/dist/module-architecture.js +587 -242
- package/dist/parse-args.js +6 -2
- package/dist/programmatic.d.ts +10 -0
- package/dist/programmatic.js +134 -19
- package/dist/site-chrome.d.ts +80 -0
- package/dist/site-chrome.js +135 -0
- package/dist/surfaces.d.ts +21 -6
- package/dist/surfaces.js +209 -36
- package/dist/template-manifest.d.ts +88 -3
- package/dist/template-manifest.js +281 -29
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +2 -0
- package/package.json +10 -2
- package/site-templates/_base/ailk-template.json +6 -0
- package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
- package/site-templates/marketing-site/ailk-template.json +11 -0
- package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
- package/site-templates/marketing-site/provenance.json +4 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
- package/site-templates/marketing-site-free/ailk-template.json +2 -1
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
- package/site-templates/minimalism-site/ailk-template.json +22 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
- package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
- package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
- package/site-templates/minimalism-site/provenance.json +4 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
- package/site-templates/saas-product/ailk-template.json +16 -0
- package/site-templates/saas-product/provenance.json +10 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.claude/skills/README.md +16 -25
- package/templates/.env.example +102 -12
- package/templates/.github/workflows/ci.yml +48 -0
- package/templates/README.md +3 -1
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
- package/templates/apps/api/.env.example +13 -0
- package/templates/apps/api/CLAUDE.md +29 -2
- package/templates/apps/api/eslint.config.mjs +69 -0
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +7 -2
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
- package/templates/apps/api/src/__tests__/server.test.ts +471 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
- package/templates/apps/api/src/config/index.ts +62 -0
- package/templates/apps/api/src/config/modules.ts +7 -3
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
- package/templates/apps/api/src/lib/content-adapter.ts +57 -31
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
- package/templates/apps/api/src/lib/error-serializer.ts +23 -3
- package/templates/apps/api/src/lib/outbound-http.ts +7 -2
- package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
- package/templates/apps/api/src/lib/site.ts +208 -9
- package/templates/apps/api/src/lib/stripe.ts +66 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
- package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
- package/templates/apps/api/src/openapi/spec.ts +543 -2
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
- package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
- package/templates/apps/api/src/routes/auth/user.ts +61 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
- package/templates/apps/api/src/routes/checkout/index.ts +34 -0
- package/templates/apps/api/src/routes/checkout/session.ts +139 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
- package/templates/apps/api/src/routes/content/get-page.ts +94 -12
- package/templates/apps/api/src/routes/content/index.ts +13 -73
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
- package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
- package/templates/apps/api/src/routes/content/search.ts +129 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/leads/list.ts +104 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
- package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
- package/templates/apps/api/src/routes/requests/index.ts +54 -0
- package/templates/apps/api/src/routes/webhooks/README.md +265 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
- package/templates/apps/api/src/server.ts +521 -55
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
- package/templates/apps/api/src/services/abandon-email.ts +8 -2
- package/templates/apps/api/src/services/aeo-score.ts +49 -12
- package/templates/apps/api/src/services/audit-log.ts +668 -0
- package/templates/apps/api/src/services/consent.ts +18 -0
- package/templates/apps/api/src/services/default-tenant.ts +300 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
- package/templates/apps/api/src/services/dunning-policy.ts +294 -0
- package/templates/apps/api/src/services/entitlements.ts +80 -0
- package/templates/apps/api/src/services/lead-promotion.ts +160 -0
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +284 -0
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
- package/templates/apps/api/src/services/project-listings.ts +201 -6
- package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
- package/templates/apps/api/src/services/request-confirm.ts +646 -0
- package/templates/apps/api/src/services/revalidation.ts +173 -0
- package/templates/apps/api/src/services/verified-email.ts +71 -0
- package/templates/apps/api/src/vercel-handler.ts +75 -35
- package/templates/apps/mcp/.env.example +17 -0
- package/templates/apps/mcp/CLAUDE.md +58 -25
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
- package/templates/apps/mcp/__tests__/index.test.ts +39 -0
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
- package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
- package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
- package/templates/apps/mcp/__tests__/server.test.ts +67 -14
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
- package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/eslint.config.mjs +69 -0
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +8 -2
- package/templates/apps/mcp/src/abuse/index.ts +159 -24
- package/templates/apps/mcp/src/config/modules.ts +10 -5
- package/templates/apps/mcp/src/http-transport.ts +102 -29
- package/templates/apps/mcp/src/index.ts +73 -11
- package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
- package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
- package/templates/apps/mcp/src/lib/prisma.ts +39 -0
- package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
- package/templates/apps/mcp/src/server.ts +61 -37
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
- package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
- package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
- package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
- package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
- package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
- package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
- package/templates/apps/mcp/src/tools/index.ts +9 -7
- package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
- package/templates/apps/mcp/src/tools/purchase.ts +128 -45
- package/templates/apps/mcp/src/tools/read_content.ts +20 -6
- package/templates/apps/mcp/src/tools/search_content.ts +11 -4
- package/templates/apps/web/.env.example +74 -8
- package/templates/apps/web/CLAUDE.md +46 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +308 -65
- package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
- package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
- package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
- package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
- package/templates/apps/web/app/api/revalidate/route.ts +194 -0
- package/templates/apps/web/auth.ts +407 -8
- package/templates/apps/web/components/google-analytics.tsx +18 -1
- package/templates/apps/web/eslint.config.mjs +69 -0
- package/templates/apps/web/jest.config.cjs +15 -19
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
- package/templates/apps/web/lib/auth-email.ts +318 -0
- package/templates/apps/web/lib/auth-env.ts +85 -0
- package/templates/apps/web/lib/auth-routes.ts +55 -0
- package/templates/apps/web/lib/meeting-slugs.ts +53 -0
- package/templates/apps/web/lib/nav-config.ts +27 -7
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/site-chrome.ts +29 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
- package/templates/apps/web/messages/en.json +216 -2
- package/templates/apps/web/next.config.mjs +20 -0
- package/templates/apps/web/package.json +8 -3
- package/templates/apps/web/sentry.client.config.ts +131 -0
- package/templates/apps/web/sentry.edge.config.ts +72 -0
- package/templates/apps/web/sentry.server.config.ts +72 -0
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
- package/templates/apps/web/src/lib/routes.ts +13 -18
- package/templates/brand_assets/README.md +24 -0
- package/templates/content/en/AboutPage/about.mdx +160 -15
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ContactPage/contact.mdx +90 -13
- package/templates/content/en/FAQPage/faq.mdx +62 -22
- package/templates/content/en/HomePage/home.mdx +244 -20
- package/templates/content/en/LandingPage/blog.mdx +83 -0
- package/templates/content/en/LandingPage/features.mdx +103 -0
- package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +149 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/seed.test.ts +1 -1
- package/templates/database/__tests__/tenancy.test.ts +141 -0
- package/templates/database/config/schema.prisma +79 -2
- package/templates/database/content/schema.prisma +87 -11
- package/templates/database/eslint.config.mjs +69 -0
- package/templates/database/inbox/schema.prisma +452 -9
- package/templates/database/jest.config.cjs +5 -3
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
- package/templates/database/package.json +7 -3
- package/templates/database/seed.ts +1 -1
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/templates/package.json +1 -14
- package/versions.json +52 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
- package/templates/apps/api/src/routes/content/create.ts +0 -115
- package/templates/apps/api/src/routes/content/delete.ts +0 -82
- package/templates/apps/api/src/routes/content/update.ts +0 -112
- package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
- package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
- package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
- package/templates/apps/mcp/src/tools/create_page.ts +0 -96
- package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
- package/templates/apps/mcp/src/tools/update_page.ts +0 -92
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
- /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
- /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
- /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file list.test.ts
|
|
3
|
+
* Route-level inject tests for `GET /v1/leads` (#5678).
|
|
4
|
+
*
|
|
5
|
+
* The scoping proof lives one layer down, in
|
|
6
|
+
* `src/services/__tests__/leads-inbox.test.ts` — it seeds two sites against a
|
|
7
|
+
* realistically filtering prisma mock and asserts a caller for one reads none
|
|
8
|
+
* of the other's. This file covers what only the HTTP boundary can show: that
|
|
9
|
+
* an unauthenticated caller is refused before the service is ever reached,
|
|
10
|
+
* that the scope cannot be widened from the query string, and that the
|
|
11
|
+
* envelope and the 400 mapping are what `apps/api/CLAUDE.md` specifies.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line no-restricted-syntax -- the service is this route's own collaborator; mocking it keeps these tests on the HTTP boundary (the scoping proof is the service's own test).
|
|
15
|
+
jest.mock("../../../services/leads-inbox.js", () => ({
|
|
16
|
+
listOwnedLeads: jest.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import type { FastifyInstance } from "fastify";
|
|
20
|
+
|
|
21
|
+
import { listOwnedLeads } from "../../../services/leads-inbox.js";
|
|
22
|
+
import { leadListRoute } from "../list";
|
|
23
|
+
|
|
24
|
+
const listOwnedLeadsMock = listOwnedLeads as jest.Mock;
|
|
25
|
+
|
|
26
|
+
const USER_ID = "user_00000000-0000-7000-8000-0000000000a1";
|
|
27
|
+
const PAGE = {
|
|
28
|
+
items: [
|
|
29
|
+
{
|
|
30
|
+
id: "lead_00000000-0000-7000-8000-0000000000d1",
|
|
31
|
+
name: "Ada Lovelace",
|
|
32
|
+
email: "ada@example.com",
|
|
33
|
+
source: "web",
|
|
34
|
+
createdAt: "2026-09-15T12:00:00.000Z",
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
nextCursor: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Builds the route with `request.userId` pre-set, standing in for the
|
|
42
|
+
* `authPreHandlerPlugin` that decorates it in `server.ts`'s `authScope`.
|
|
43
|
+
* `userId: undefined` is the anonymous case — the same state the decorator
|
|
44
|
+
* leaves behind when no session resolves.
|
|
45
|
+
*/
|
|
46
|
+
async function buildApp(userId: string | undefined): Promise<FastifyInstance> {
|
|
47
|
+
const fastify = require("fastify")();
|
|
48
|
+
fastify.decorateRequest("userId", undefined);
|
|
49
|
+
fastify.addHook("onRequest", (request: { userId?: string }, _reply: unknown, done: () => void) => {
|
|
50
|
+
request.userId = userId;
|
|
51
|
+
done();
|
|
52
|
+
});
|
|
53
|
+
await fastify.register(leadListRoute);
|
|
54
|
+
return fastify;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("GET /v1/leads", () => {
|
|
58
|
+
let app: FastifyInstance;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
jest.clearAllMocks();
|
|
62
|
+
listOwnedLeadsMock.mockResolvedValue({ ok: true, value: PAGE });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
await app.close();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("401s an unauthenticated caller without touching the read service", async () => {
|
|
70
|
+
app = await buildApp(undefined);
|
|
71
|
+
|
|
72
|
+
const res = await app.inject({ method: "GET", url: "/v1/leads" });
|
|
73
|
+
|
|
74
|
+
expect(res.statusCode).toBe(401);
|
|
75
|
+
expect(res.json()).toEqual({
|
|
76
|
+
ok: false,
|
|
77
|
+
error: { code: "unauthorized", message: "Authentication required" },
|
|
78
|
+
});
|
|
79
|
+
expect(listOwnedLeadsMock).not.toHaveBeenCalled();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns the page in the { ok: true, data } envelope", async () => {
|
|
83
|
+
app = await buildApp(USER_ID);
|
|
84
|
+
|
|
85
|
+
const res = await app.inject({ method: "GET", url: "/v1/leads" });
|
|
86
|
+
|
|
87
|
+
expect(res.statusCode).toBe(200);
|
|
88
|
+
expect(res.json()).toEqual({ ok: true, data: PAGE });
|
|
89
|
+
expect(listOwnedLeadsMock).toHaveBeenCalledWith({
|
|
90
|
+
userId: USER_ID,
|
|
91
|
+
cursor: undefined,
|
|
92
|
+
limit: 50,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("ignores a caller-supplied siteId — the scope cannot be widened from the wire", async () => {
|
|
97
|
+
app = await buildApp(USER_ID);
|
|
98
|
+
|
|
99
|
+
const res = await app.inject({
|
|
100
|
+
method: "GET",
|
|
101
|
+
url: "/v1/leads?siteId=someone-elses-site&workspaceId=another",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(res.statusCode).toBe(200);
|
|
105
|
+
// The service is called with the caller's identity and the paging
|
|
106
|
+
// parameters only — no site, org or workspace value reaches it.
|
|
107
|
+
expect(listOwnedLeadsMock).toHaveBeenCalledWith({
|
|
108
|
+
userId: USER_ID,
|
|
109
|
+
cursor: undefined,
|
|
110
|
+
limit: 50,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("400s a malformed cursor before any read", async () => {
|
|
115
|
+
app = await buildApp(USER_ID);
|
|
116
|
+
|
|
117
|
+
const res = await app.inject({ method: "GET", url: "/v1/leads?cursor=not-a-lead-id" });
|
|
118
|
+
|
|
119
|
+
expect(res.statusCode).toBe(400);
|
|
120
|
+
expect(res.json().error.code).toBe("validation_failed");
|
|
121
|
+
expect(listOwnedLeadsMock).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("400s a limit above the 200 ceiling", async () => {
|
|
125
|
+
app = await buildApp(USER_ID);
|
|
126
|
+
|
|
127
|
+
const res = await app.inject({ method: "GET", url: "/v1/leads?limit=1000" });
|
|
128
|
+
|
|
129
|
+
expect(res.statusCode).toBe(400);
|
|
130
|
+
expect(listOwnedLeadsMock).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("maps the service's invalid_cursor to a 400, not a 404 or a silent page 1", async () => {
|
|
134
|
+
app = await buildApp(USER_ID);
|
|
135
|
+
listOwnedLeadsMock.mockResolvedValue({
|
|
136
|
+
ok: false,
|
|
137
|
+
error: { kind: "invalid_cursor", message: "cursor does not resolve within this scope" },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const res = await app.inject({
|
|
141
|
+
method: "GET",
|
|
142
|
+
url: "/v1/leads?cursor=lead_00000000-0000-7000-8000-0000000000d9",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(res.statusCode).toBe(400);
|
|
146
|
+
expect(res.json()).toEqual({
|
|
147
|
+
ok: false,
|
|
148
|
+
error: {
|
|
149
|
+
code: "validation_failed",
|
|
150
|
+
message: "cursor does not resolve within this scope",
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("500s an internal error without surfacing its detail", async () => {
|
|
156
|
+
app = await buildApp(USER_ID);
|
|
157
|
+
listOwnedLeadsMock.mockResolvedValue({
|
|
158
|
+
ok: false,
|
|
159
|
+
error: { kind: "internal_error", message: "connect ECONNREFUSED 127.0.0.1:5432" },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const res = await app.inject({ method: "GET", url: "/v1/leads" });
|
|
163
|
+
|
|
164
|
+
expect(res.statusCode).toBe(500);
|
|
165
|
+
expect(res.json()).toEqual({
|
|
166
|
+
ok: false,
|
|
167
|
+
error: { code: "internal_error", message: "Failed to read leads" },
|
|
168
|
+
});
|
|
169
|
+
expect(res.body).not.toContain("ECONNREFUSED");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -5,16 +5,32 @@
|
|
|
5
5
|
* POST /v1/leads/submit — lead submission (contact form / inquiry)
|
|
6
6
|
* POST /v1/leads/subscribe — newsletter subscription
|
|
7
7
|
* POST /v1/leads/callback — callback request (phone-back)
|
|
8
|
+
*
|
|
9
|
+
* TENANT RESOLUTION (#6203, spec §10.4 operator ruling — reverses this
|
|
10
|
+
* file's former byte-unchanged posture). `siteId` used to be taken VERBATIM
|
|
11
|
+
* from the request body (`body["siteId"] as string`) — an anonymous, no-
|
|
12
|
+
* ownership-check value (`packages/validation/src/schemas/leads.ts`'s
|
|
13
|
+
* `safeSiteIdString` permits a `wksp_<uuidv7>`-shaped literal). Every
|
|
14
|
+
* capture kind now resolves its tenant SERVER-SIDE, via
|
|
15
|
+
* `resolveSiteWorkspaceId()` (`lib/site.ts`) — the SAME alias -> workspace
|
|
16
|
+
* resolution `currentSiteId()`/`AILK_SITE_ID` already drive throughout this
|
|
17
|
+
* app, unconditional on the content adapter — and a client-posted `siteId`
|
|
18
|
+
* is IGNORED entirely: it can no longer place a row anywhere. An unresolved
|
|
19
|
+
* result (no DATABASE_URL, or the alias names no workspace this database
|
|
20
|
+
* holds) is a 503 before any write is attempted — never a guess. See
|
|
21
|
+
* `lead-routing.ts`'s own header for the persistence-side half.
|
|
8
22
|
*/
|
|
9
23
|
|
|
10
24
|
import { track } from "@working-theory/analytics/server";
|
|
11
25
|
import type { FastifyPluginAsync } from "fastify";
|
|
12
26
|
|
|
27
|
+
import { currentSiteId, resolveSiteWorkspaceId } from "../../lib/site.js";
|
|
13
28
|
import {
|
|
14
29
|
type LeadKind,
|
|
15
30
|
type RouteLeadInput,
|
|
16
31
|
routeLead,
|
|
17
32
|
} from "../../services/lead-routing.js";
|
|
33
|
+
import { sendRequestConfirmLink } from "../../services/request-confirm.js";
|
|
18
34
|
|
|
19
35
|
/**
|
|
20
36
|
* Read a named string field from the body, falling back to the same-named
|
|
@@ -42,17 +58,22 @@ function liftString(
|
|
|
42
58
|
* `email`/`name`/`phone` out of `payload` (see liftString above) reads by
|
|
43
59
|
* explicit field name only — it does not spread `payload`'s other keys, so
|
|
44
60
|
* the same discipline holds. `payload` itself is passed through unchanged.
|
|
61
|
+
*
|
|
62
|
+
* `siteId` is the RESOLVED `workspaceId` (#6203) — never `body["siteId"]`.
|
|
63
|
+
* A body-supplied `siteId` is read nowhere in this function; a client that
|
|
64
|
+
* posts one has it silently ignored, never honoured.
|
|
45
65
|
*/
|
|
46
66
|
function buildInput(
|
|
47
67
|
kind: LeadKind,
|
|
48
68
|
body: Record<string, unknown>,
|
|
69
|
+
workspaceId: string,
|
|
49
70
|
): RouteLeadInput {
|
|
50
71
|
const payload = (body["payload"] ?? {}) as Record<string, unknown>;
|
|
51
72
|
|
|
52
73
|
if (kind === "submit") {
|
|
53
74
|
return {
|
|
54
75
|
kind,
|
|
55
|
-
siteId:
|
|
76
|
+
siteId: workspaceId,
|
|
56
77
|
source: body["source"] as string,
|
|
57
78
|
email: liftString(body, payload, "email"),
|
|
58
79
|
payload,
|
|
@@ -61,14 +82,14 @@ function buildInput(
|
|
|
61
82
|
if (kind === "subscribe") {
|
|
62
83
|
return {
|
|
63
84
|
kind,
|
|
64
|
-
siteId:
|
|
85
|
+
siteId: workspaceId,
|
|
65
86
|
email: liftString(body, payload, "email"),
|
|
66
87
|
source: body["source"] as string,
|
|
67
88
|
};
|
|
68
89
|
}
|
|
69
90
|
return {
|
|
70
91
|
kind,
|
|
71
|
-
siteId:
|
|
92
|
+
siteId: workspaceId,
|
|
72
93
|
name: liftString(body, payload, "name"),
|
|
73
94
|
phone: liftString(body, payload, "phone"),
|
|
74
95
|
...(body["preferredTime"] !== undefined && {
|
|
@@ -99,7 +120,46 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
99
120
|
receivedAt: new Date().toISOString(),
|
|
100
121
|
};
|
|
101
122
|
|
|
102
|
-
|
|
123
|
+
// #6203 — resolve the tenant SERVER-SIDE before any write is
|
|
124
|
+
// attempted. Two branches, deliberately different:
|
|
125
|
+
//
|
|
126
|
+
// - No DATABASE_URL: the zero-config OSS file-fallback deployment.
|
|
127
|
+
// There is no `workspaces` table to resolve against (and
|
|
128
|
+
// `assertSiteAliasResolves()` already skips its own boot check for
|
|
129
|
+
// exactly this reason), so the "resolved" value here is the site
|
|
130
|
+
// ALIAS itself (`currentSiteId()`) — the same value the file
|
|
131
|
+
// fallback would have carried before this unit. Never a rejection:
|
|
132
|
+
// that would break lead capture for every zero-config scaffold.
|
|
133
|
+
// - DATABASE_URL set: resolve via `resolveSiteWorkspaceId()`, the
|
|
134
|
+
// SAME alias -> workspace resolution `currentSiteId()` already
|
|
135
|
+
// drives everywhere else in this app. `null` here is a genuine
|
|
136
|
+
// misconfiguration `build()` already refuses to boot past
|
|
137
|
+
// (`assertSiteAliasResolves()`) — reaching it at request time is
|
|
138
|
+
// the residual case that startup check cannot catch (a workspace
|
|
139
|
+
// deleted after boot, a transient read), and it fails closed the
|
|
140
|
+
// same way: reject, never guess (spec AC-4).
|
|
141
|
+
let workspaceId: string;
|
|
142
|
+
if (!process.env.DATABASE_URL) {
|
|
143
|
+
workspaceId = currentSiteId();
|
|
144
|
+
} else {
|
|
145
|
+
const resolved = await resolveSiteWorkspaceId();
|
|
146
|
+
if (resolved === null) {
|
|
147
|
+
request.log.error(
|
|
148
|
+
{ kind },
|
|
149
|
+
"lead capture: DATABASE_URL is set but the site alias resolves to no workspace",
|
|
150
|
+
);
|
|
151
|
+
return reply.status(503).send({
|
|
152
|
+
error: "tenant_unresolved",
|
|
153
|
+
message: "This site is not configured to accept submissions.",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
workspaceId = resolved;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = await routeLead(
|
|
160
|
+
buildInput(kind, body, workspaceId),
|
|
161
|
+
sourceMeta,
|
|
162
|
+
);
|
|
103
163
|
|
|
104
164
|
if (!result.ok) {
|
|
105
165
|
if (result.error.kind === "invalid_input") {
|
|
@@ -110,7 +170,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
110
170
|
if (result.error.kind === "persist_failed") {
|
|
111
171
|
// Do not surface internal error detail — log it server-side only
|
|
112
172
|
request.log.error(
|
|
113
|
-
{ kind, siteId:
|
|
173
|
+
{ kind, siteId: workspaceId, error: result.error.message },
|
|
114
174
|
"lead persist_failed",
|
|
115
175
|
);
|
|
116
176
|
return reply.status(503).send({ error: "persist_failed" });
|
|
@@ -131,7 +191,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
131
191
|
: "error";
|
|
132
192
|
request.log.info(
|
|
133
193
|
{
|
|
134
|
-
siteId:
|
|
194
|
+
siteId: workspaceId,
|
|
135
195
|
kind,
|
|
136
196
|
ip: sourceMeta.ip,
|
|
137
197
|
persisted: result.persisted,
|
|
@@ -140,6 +200,35 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
140
200
|
"lead routed",
|
|
141
201
|
);
|
|
142
202
|
|
|
203
|
+
// Request-and-confirm (#5970): mint the tenant's one-time confirm
|
|
204
|
+
// credential and email them the link. Fire-and-forget and `void`-
|
|
205
|
+
// returning, so the 201 below stays independent of both Resend and that
|
|
206
|
+
// write — the request is already persisted by this point, and a send
|
|
207
|
+
// that fails must never turn a captured lead into an error.
|
|
208
|
+
//
|
|
209
|
+
// Callback only: `submit` and `subscribe` have no confirm step. The
|
|
210
|
+
// service itself no-ops (with a logged reason) when the feature is not
|
|
211
|
+
// configured, which is the default.
|
|
212
|
+
// `result.persisted === "db"` for the same reason `lead-routing.ts`
|
|
213
|
+
// consults it (#5970 security review round 3, M1): the credential is
|
|
214
|
+
// minted by an update on the `callbacks` row, so a FILE-FALLBACK id
|
|
215
|
+
// would match nothing and the operator would be left with no email at
|
|
216
|
+
// all. On that path the generic notification is sent instead, which is
|
|
217
|
+
// what keeps "exactly one" true in both directions.
|
|
218
|
+
if (kind === "callback" && result.persisted === "db") {
|
|
219
|
+
const callbackInput = buildInput(kind, body, workspaceId) as Extract<
|
|
220
|
+
RouteLeadInput,
|
|
221
|
+
{ kind: "callback" }
|
|
222
|
+
>;
|
|
223
|
+
sendRequestConfirmLink(result.id, {
|
|
224
|
+
name: callbackInput.name,
|
|
225
|
+
phone: callbackInput.phone,
|
|
226
|
+
...(callbackInput.preferredTime !== undefined && {
|
|
227
|
+
preferredTime: callbackInput.preferredTime,
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
143
232
|
// Fire-and-forget: track() never throws; no need to hold the response.
|
|
144
233
|
void track("lead-captured", {
|
|
145
234
|
source: body["source"] as string | undefined,
|
|
@@ -148,13 +237,11 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
|
|
|
148
237
|
] as string | undefined,
|
|
149
238
|
});
|
|
150
239
|
|
|
151
|
-
return reply
|
|
152
|
-
.
|
|
153
|
-
.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
email: result.email,
|
|
157
|
-
});
|
|
240
|
+
return reply.status(201).send({
|
|
241
|
+
id: result.id,
|
|
242
|
+
persisted: result.persisted,
|
|
243
|
+
email: result.email,
|
|
244
|
+
});
|
|
158
245
|
});
|
|
159
246
|
};
|
|
160
247
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The leads INBOX read (#5678).
|
|
3
|
+
*
|
|
4
|
+
* Routes:
|
|
5
|
+
* GET /v1/leads?cursor=&limit= — the caller's own sites' leads, newest first
|
|
6
|
+
*
|
|
7
|
+
* Registered INSIDE `server.ts`'s `authScope` (auth only — NOT the inner
|
|
8
|
+
* `tenantScope`, which the multi-tenant module owns), beside
|
|
9
|
+
* `/v1/waitlist-signups/me` and `/v1/project-listings/me`. Lead capture is a
|
|
10
|
+
* never-gate surface at every level, so reading a lead back must be too: this
|
|
11
|
+
* route is registered unconditionally, not behind an `isModuleEnabled()` gate.
|
|
12
|
+
* Registering everywhere is not the same as RETURNING a row everywhere —
|
|
13
|
+
* `services/leads-inbox.ts` § "What it takes for this to return anything"
|
|
14
|
+
* names the two deployment preconditions, both of which fail empty, never
|
|
15
|
+
* wide.
|
|
16
|
+
*
|
|
17
|
+
* Identity is `request.userId` ONLY. There is no site, org or workspace
|
|
18
|
+
* parameter — the query schema declares `cursor` and `limit` and nothing else
|
|
19
|
+
* — so the scope cannot be widened from the wire. `services/leads-inbox.ts`
|
|
20
|
+
* resolves it from the caller's own memberships through
|
|
21
|
+
* `services/owner-workspaces.ts`'s `resolveOwnerWorkspaces` and filters every
|
|
22
|
+
* read on `siteId ∈ workspaceIds`.
|
|
23
|
+
*
|
|
24
|
+
* Envelope per apps/api/CLAUDE.md: `{ ok: true, data }` /
|
|
25
|
+
* `{ ok: false, error: { code, message } }`, real HTTP status codes:
|
|
26
|
+
* 400 validation_failed · 401 unauthorized · 500 internal_error.
|
|
27
|
+
*
|
|
28
|
+
* No response-contract re-parse (the `waitlist-experiments/respond.ts`
|
|
29
|
+
* `sendValidated` net): the five fields of a row are assembled one by one in
|
|
30
|
+
* `leads-inbox.ts` from five explicitly selected columns, with no
|
|
31
|
+
* pass-through of a stored value that could carry an unexpected shape — so
|
|
32
|
+
* there is no drift for that net to catch.
|
|
33
|
+
*
|
|
34
|
+
* API↔MCP parity: identity-bound (the session IS the scope; the OSS MCP
|
|
35
|
+
* surface is identity-blind) — no MCP twin (#3591 exception, the same shape as
|
|
36
|
+
* `/v1/workspaces/*`, `/v1/billing/*` and `/v1/waitlist-experiments/*`).
|
|
37
|
+
*/
|
|
38
|
+
import { zLeadId } from "@working-theory/ids";
|
|
39
|
+
import type { FastifyPluginAsync, FastifyReply } from "fastify";
|
|
40
|
+
import { z } from "zod";
|
|
41
|
+
|
|
42
|
+
import { listOwnedLeads } from "../../services/leads-inbox.js";
|
|
43
|
+
|
|
44
|
+
const DEFAULT_LIMIT = 50;
|
|
45
|
+
const MAX_LIMIT = 200;
|
|
46
|
+
|
|
47
|
+
const emptyToUndefined = (val: unknown) => (val === "" ? undefined : val);
|
|
48
|
+
|
|
49
|
+
/** Boundary validation, mirroring `waitlist-experiments/signups.ts`'s query schema. */
|
|
50
|
+
const listQuerySchema = z.object({
|
|
51
|
+
cursor: z.preprocess(emptyToUndefined, zLeadId.optional()),
|
|
52
|
+
limit: z.preprocess(
|
|
53
|
+
emptyToUndefined,
|
|
54
|
+
z.coerce.number().int().min(1).max(MAX_LIMIT).optional(),
|
|
55
|
+
),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
function unauthorized(reply: FastifyReply) {
|
|
59
|
+
return reply.status(401).send({
|
|
60
|
+
ok: false,
|
|
61
|
+
error: { code: "unauthorized", message: "Authentication required" },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function invalidQuery(reply: FastifyReply, message: string) {
|
|
66
|
+
return reply.status(400).send({
|
|
67
|
+
ok: false,
|
|
68
|
+
error: { code: "validation_failed", message },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const leadListRoute: FastifyPluginAsync = async (fastify) => {
|
|
73
|
+
fastify.get("/v1/leads", async (request, reply) => {
|
|
74
|
+
const userId = request.userId;
|
|
75
|
+
if (!userId) return unauthorized(reply);
|
|
76
|
+
|
|
77
|
+
const query = listQuerySchema.safeParse(request.query);
|
|
78
|
+
if (!query.success) return invalidQuery(reply, "Invalid query parameters");
|
|
79
|
+
|
|
80
|
+
const result = await listOwnedLeads({
|
|
81
|
+
userId,
|
|
82
|
+
cursor: query.data.cursor,
|
|
83
|
+
limit: query.data.limit ?? DEFAULT_LIMIT,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!result.ok) {
|
|
87
|
+
if (result.error.kind === "invalid_cursor") {
|
|
88
|
+
return invalidQuery(reply, result.error.message);
|
|
89
|
+
}
|
|
90
|
+
// Do not surface internal error detail — log it server-side only, and
|
|
91
|
+
// with no lead field in the log line (the leads PII boundary).
|
|
92
|
+
request.log.error(
|
|
93
|
+
{ route: "leads/list", error: result.error.message },
|
|
94
|
+
"leads list failed",
|
|
95
|
+
);
|
|
96
|
+
return reply.status(500).send({
|
|
97
|
+
ok: false,
|
|
98
|
+
error: { code: "internal_error", message: "Failed to read leads" },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return reply.status(200).send({ ok: true, data: result.value });
|
|
103
|
+
});
|
|
104
|
+
};
|
package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts
CHANGED
|
@@ -74,6 +74,10 @@ import {
|
|
|
74
74
|
} from "@working-theory/validation";
|
|
75
75
|
|
|
76
76
|
import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
|
|
77
|
+
import {
|
|
78
|
+
mockNoWorkspace,
|
|
79
|
+
mockWorkspaceExists,
|
|
80
|
+
} from "../../../lib/__mocks__/prisma.js";
|
|
77
81
|
import { prisma } from "../../../lib/prisma.js";
|
|
78
82
|
import { patchProjectListingDraftRoute } from "../patch-draft.js";
|
|
79
83
|
import { startProjectListingRoute } from "../start.js";
|
|
@@ -181,6 +185,13 @@ function matches(row: Row, where: Record<string, unknown>): boolean {
|
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
function installStore(): void {
|
|
188
|
+
// #6335 — the shared prisma mock no longer resolves ANY requested
|
|
189
|
+
// workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
|
|
190
|
+
// by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
|
|
191
|
+
// services/project-listings.ts), so this suite states the ONE site it is
|
|
192
|
+
// built around. Keyed on the requested id — nothing else resolves.
|
|
193
|
+
mockWorkspaceExists(prisma, SITE_ID);
|
|
194
|
+
|
|
184
195
|
rows = [];
|
|
185
196
|
nextId = 1;
|
|
186
197
|
configData = null;
|
|
@@ -239,6 +250,7 @@ function installStore(): void {
|
|
|
239
250
|
headline: null,
|
|
240
251
|
logoMarkUrl: null,
|
|
241
252
|
logoWordmarkUrl: null,
|
|
253
|
+
solution: null,
|
|
242
254
|
featured: false,
|
|
243
255
|
decidedAt: null,
|
|
244
256
|
decidedBy: null,
|
|
@@ -573,3 +585,42 @@ describe("the site's own application config (#5048 D5/D6)", () => {
|
|
|
573
585
|
});
|
|
574
586
|
});
|
|
575
587
|
});
|
|
588
|
+
|
|
589
|
+
// ── #6335 — the explicit workspace setup above is load-bearing ────────────
|
|
590
|
+
//
|
|
591
|
+
// Every case in this file states that this suite's site has a workspace.
|
|
592
|
+
// This one takes that away, and proves the statement was doing work rather
|
|
593
|
+
// than decorating the setup: `startProjectListingDraft` resolves the site it
|
|
594
|
+
// is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
|
|
595
|
+
// and refuses BEFORE any row is written. With the shared prisma mock's old
|
|
596
|
+
// permissive default in place this case could not be written at all — every
|
|
597
|
+
// id resolved, so the fail-closed path was unreachable from a route test.
|
|
598
|
+
describe("#6335 — start fails closed when the site names no workspace", () => {
|
|
599
|
+
let app: FastifyInstance;
|
|
600
|
+
|
|
601
|
+
beforeEach(async () => {
|
|
602
|
+
jest.clearAllMocks();
|
|
603
|
+
installStore();
|
|
604
|
+
mockNoWorkspace(prisma);
|
|
605
|
+
app = await buildApp();
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
afterEach(async () => {
|
|
609
|
+
await app.close();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
|
|
613
|
+
const res = await app.inject({
|
|
614
|
+
method: "POST",
|
|
615
|
+
url: "/v1/project-listings",
|
|
616
|
+
payload: START_PAYLOAD,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
expect(res.statusCode).toBe(503);
|
|
620
|
+
expect((res.json() as { error: { code: string } }).error.code).toBe(
|
|
621
|
+
"tenant_unresolved",
|
|
622
|
+
);
|
|
623
|
+
expect(prisma.projectListing.create).not.toHaveBeenCalled();
|
|
624
|
+
expect(rows).toHaveLength(0);
|
|
625
|
+
});
|
|
626
|
+
});
|