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
|
@@ -10,6 +10,18 @@
|
|
|
10
10
|
*
|
|
11
11
|
* PII boundary: only { siteId, kind, ip, persisted, emailKind } appear in logs.
|
|
12
12
|
* Never log email, phone, name, or payload values.
|
|
13
|
+
*
|
|
14
|
+
* TENANCY (#6203). `input.siteId` is the caller's RESOLVED workspace id —
|
|
15
|
+
* `routes/leads/index.ts` resolves it server-side before calling `routeLead`
|
|
16
|
+
* and never a client-supplied value (see that file's header). `Lead` gets a
|
|
17
|
+
* `workspaceId` stamp of the same value, and since #6374 so do `Subscriber`
|
|
18
|
+
* and `Callback` — both gained a required `workspaceId` column in the fence
|
|
19
|
+
* fan-out, stamped from the same resolved id by the three `create` sites
|
|
20
|
+
* below. (This line previously said those two models had no such column;
|
|
21
|
+
* that stopped being true when the keystone migration landed, and the code
|
|
22
|
+
* under it already contradicted it.) The one READ in this file — the
|
|
23
|
+
* duplicate-subscriber lookup on the P2002 arm — is scoped by it too
|
|
24
|
+
* (#6257).
|
|
13
25
|
*/
|
|
14
26
|
|
|
15
27
|
import { randomUUID } from "node:crypto";
|
|
@@ -23,6 +35,7 @@ import { appendLeadRecord } from "../lib/lead-capture-store.js";
|
|
|
23
35
|
import { prisma } from "../lib/prisma.js";
|
|
24
36
|
|
|
25
37
|
import { enrichLead } from "./enrichment-shim.js";
|
|
38
|
+
import { requestConfirmSupersedesNotification } from "./request-confirm.js";
|
|
26
39
|
|
|
27
40
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
28
41
|
|
|
@@ -103,6 +116,23 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
|
|
|
103
116
|
const row = (await prisma.lead.create({
|
|
104
117
|
data: {
|
|
105
118
|
siteId: input.siteId,
|
|
119
|
+
// #6203/#6254 — the isolation key, equal to `siteId` by
|
|
120
|
+
// construction. `input.siteId` is the RESOLVED workspace id at
|
|
121
|
+
// EVERY `routeLead(kind: "submit")` caller now — the primary route
|
|
122
|
+
// (`routes/leads/index.ts`, never reads it from the body — see that
|
|
123
|
+
// file's header) and the three service-layer callers
|
|
124
|
+
// (`flow-engine.ts`'s `completeSubmission`,
|
|
125
|
+
// `recommender-capture.ts`'s `captureRecommenderChoice`,
|
|
126
|
+
// `project-listings.ts`'s `submitProjectListingDraft`) all resolve
|
|
127
|
+
// the site alias before calling in (#6254 AC-6/AC-10). `workspaceId`
|
|
128
|
+
// is NOT NULL as of
|
|
129
|
+
// database/migrations/20260922120000_tenancy_fence, so this can no
|
|
130
|
+
// longer stamp `null` for an unresolved alias the way it once did
|
|
131
|
+
// (spec D2's old rule) — a caller that somehow still passed one
|
|
132
|
+
// would instead hit the pre-existing `<table>_workspace_id_format`
|
|
133
|
+
// CHECK loudly, via the AC-11 re-raise below, rather than landing a
|
|
134
|
+
// silently-unfenced row.
|
|
135
|
+
workspaceId: input.siteId,
|
|
106
136
|
source: input.source,
|
|
107
137
|
email: input.email,
|
|
108
138
|
payload: input.payload as Prisma.InputJsonValue,
|
|
@@ -117,6 +147,13 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
|
|
|
117
147
|
const row = (await prisma.subscriber.create({
|
|
118
148
|
data: {
|
|
119
149
|
siteId: input.siteId,
|
|
150
|
+
// #6374 — the isolation key, equal to `siteId` by construction,
|
|
151
|
+
// exactly as `lead.create` above stamps it. `input.siteId` is the
|
|
152
|
+
// RESOLVED workspace id at every `routeLead` caller (see the
|
|
153
|
+
// `submit` branch's own note); a caller that somehow passed a raw
|
|
154
|
+
// alias would hit `subscribers_workspace_id_format` loudly rather
|
|
155
|
+
// than landing a silently-unfenced row. Never from a request body.
|
|
156
|
+
workspaceId: input.siteId,
|
|
120
157
|
email: input.email,
|
|
121
158
|
source: input.source,
|
|
122
159
|
},
|
|
@@ -131,8 +168,15 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
|
|
|
131
168
|
"code" in err &&
|
|
132
169
|
(err as { code: string }).code === "P2002"
|
|
133
170
|
) {
|
|
171
|
+
// #6257 — the duplicate lookup is scoped by the same resolved
|
|
172
|
+
// workspace id the `create` above stamps (`input.siteId` is the
|
|
173
|
+
// RESOLVED id at every caller — see the `submit` branch's note), so a
|
|
174
|
+
// P2002 on another tenant's row can never resolve to its id.
|
|
134
175
|
const existing = (await prisma.subscriber.findUnique({
|
|
135
|
-
where: {
|
|
176
|
+
where: {
|
|
177
|
+
siteId_email: { siteId: input.siteId, email: input.email },
|
|
178
|
+
workspaceId: input.siteId,
|
|
179
|
+
},
|
|
136
180
|
select: { id: true },
|
|
137
181
|
})) as DbRow | null;
|
|
138
182
|
const existingId = existing?.id ?? "duplicate-no-id";
|
|
@@ -152,6 +196,10 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
|
|
|
152
196
|
const row = (await prisma.callback.create({
|
|
153
197
|
data: {
|
|
154
198
|
siteId: input.siteId,
|
|
199
|
+
// #6374 — the isolation key, equal to `siteId` by construction; same
|
|
200
|
+
// resolution guarantee and same loud-failure posture as the two
|
|
201
|
+
// branches above. Never from a request body (AC-6).
|
|
202
|
+
workspaceId: input.siteId,
|
|
155
203
|
name: input.name,
|
|
156
204
|
phone: input.phone,
|
|
157
205
|
...(input.preferredTime !== undefined && {
|
|
@@ -164,6 +212,56 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
|
|
|
164
212
|
return { ok: true, id: row.id, source: "db", skipEmail: false };
|
|
165
213
|
}
|
|
166
214
|
|
|
215
|
+
// ─── Constraint-violation detection (#6254 AC-11) ──────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* SQLSTATE class 23 — "integrity constraint violation" — covers a NOT NULL
|
|
219
|
+
* (23502), a CHECK (23514), a unique (23505) or a foreign-key (23503)
|
|
220
|
+
* violation. Two of Prisma's own mapped codes correspond to a class-23
|
|
221
|
+
* violation on a `.create()` call: P2002 (unique) and P2011 (null
|
|
222
|
+
* constraint) — both `PrismaClientKnownRequestError`s Prisma classifies
|
|
223
|
+
* from its OWN schema knowledge. The new `<table>_workspace_site_agree`
|
|
224
|
+
* CHECK (database/migrations/20260922120000_tenancy_fence) has no such
|
|
225
|
+
* schema-level representation — Prisma cannot classify a raw CHECK it does
|
|
226
|
+
* not know about, so the raw Postgres SQLSTATE is read defensively from
|
|
227
|
+
* wherever the error actually carries it: `error.meta.code` (some engine/
|
|
228
|
+
* adapter combinations thread it through there) or the error message text
|
|
229
|
+
* (every Postgres client library's message includes the SQLSTATE and/or the
|
|
230
|
+
* words "constraint"/"null value" verbatim).
|
|
231
|
+
*/
|
|
232
|
+
// P2000 (value too long) is deliberately excluded — a data-length validation
|
|
233
|
+
// error, not a SQLSTATE class-23 integrity-constraint violation (security
|
|
234
|
+
// review finding, PR #6360): it doesn't correspond to 23502/23503/23505/
|
|
235
|
+
// 23514, so treating it as one would take this path for a case AC-11 does
|
|
236
|
+
// not target.
|
|
237
|
+
const PRISMA_CLASS_23_CODES = new Set(["P2002", "P2003", "P2004", "P2011"]);
|
|
238
|
+
|
|
239
|
+
function isConstraintViolation(error: unknown): boolean {
|
|
240
|
+
if (error == null || typeof error !== "object") return false;
|
|
241
|
+
|
|
242
|
+
const code = (error as { code?: unknown }).code;
|
|
243
|
+
if (typeof code === "string" && PRISMA_CLASS_23_CODES.has(code)) return true;
|
|
244
|
+
|
|
245
|
+
const metaCode = (error as { meta?: { code?: unknown } }).meta?.code;
|
|
246
|
+
if (typeof metaCode === "string" && /^23\d{3}$/.test(metaCode)) return true;
|
|
247
|
+
|
|
248
|
+
const message = (error as { message?: unknown }).message;
|
|
249
|
+
if (typeof message === "string") {
|
|
250
|
+
// Labeled SQLSTATE only — "code: '23514'", "(SQLSTATE 23502)", etc. A
|
|
251
|
+
// bare `\b23\d{3}\b` anywhere in the message (security review finding,
|
|
252
|
+
// PR #6360) would also match a coincidental 5-digit number in a genuine
|
|
253
|
+
// outage's own text (a port, a byte count, a timeout in ms), stealing
|
|
254
|
+
// that case away from the file fallback it should still take.
|
|
255
|
+
if (/\b(?:sqlstate|code)[:\s]*['"]?23\d{3}['"]?/i.test(message)) return true;
|
|
256
|
+
if (/violates (check|not-null|unique|foreign key) constraint/i.test(message)) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
if (/null value in column/i.test(message)) return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
167
265
|
// ─── Email ────────────────────────────────────────────────────────────────────
|
|
168
266
|
|
|
169
267
|
type EmailResult =
|
|
@@ -174,7 +272,38 @@ type EmailResult =
|
|
|
174
272
|
async function sendEmail(
|
|
175
273
|
input: RouteLeadInput,
|
|
176
274
|
id: string,
|
|
275
|
+
persisted: "db" | "file",
|
|
177
276
|
): Promise<EmailResult> {
|
|
277
|
+
// Request-and-confirm SUPERSEDES this notification for a callback (#5970
|
|
278
|
+
// D6). AC-1 requires the tenant to receive exactly one email, and the
|
|
279
|
+
// confirm message carries strictly more than this one does — the same
|
|
280
|
+
// request details, plus the link that confirms a time and a price. Sending
|
|
281
|
+
// both would be two; sending this one and not that one would lose the
|
|
282
|
+
// feature. `submit` and `subscribe` are untouched.
|
|
283
|
+
//
|
|
284
|
+
// The predicate lives in request-confirm.ts and is read by both sides, so
|
|
285
|
+
// the two cannot both fire. It does NOT by itself rule out zero — see that
|
|
286
|
+
// function's header for what the coupling does and does not guarantee, and
|
|
287
|
+
// for the `persisted` term below, which is what closed the one zero case
|
|
288
|
+
// that was reachable in production. With the feature unconfigured — the
|
|
289
|
+
// default, and what every existing leads suite runs under — this is false
|
|
290
|
+
// and every byte below is reached as before.
|
|
291
|
+
// `persisted === "db"` is load-bearing, not decoration (#5970 security
|
|
292
|
+
// review round 3, M1). The confirm message can only supersede this one if it
|
|
293
|
+
// is actually going to be sent, and it can only be sent against a ROW: the
|
|
294
|
+
// credential is minted by an `updateMany` on `callbacks`. On the FILE
|
|
295
|
+
// FALLBACK path — a DB outage, an unset DATABASE_URL, an exhausted pool —
|
|
296
|
+
// there is no row, so suppressing here would have produced ZERO operator
|
|
297
|
+
// emails for every callback captured during the outage, on the one surface
|
|
298
|
+
// whose whole purpose is that an outage must not lose a lead.
|
|
299
|
+
if (
|
|
300
|
+
input.kind === "callback" &&
|
|
301
|
+
persisted === "db" &&
|
|
302
|
+
requestConfirmSupersedesNotification()
|
|
303
|
+
) {
|
|
304
|
+
return { skipped: true, reason: "request_confirm" };
|
|
305
|
+
}
|
|
306
|
+
|
|
178
307
|
const to = process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
179
308
|
const from = process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
180
309
|
|
|
@@ -265,6 +394,36 @@ export async function routeLead(
|
|
|
265
394
|
skipEmail = dbResult.skipEmail;
|
|
266
395
|
skipReason = dbResult.skipReason;
|
|
267
396
|
} catch (dbErr) {
|
|
397
|
+
// #6254 AC-11 (spec D6, SEC-02) — a constraint violation is OUR OWN
|
|
398
|
+
// derivation producing a row Postgres itself refuses (an unresolved
|
|
399
|
+
// workspaceId hitting NOT NULL, or a site<->workspace disagreement
|
|
400
|
+
// hitting the new equality CHECK) — a bug to surface, never an outage
|
|
401
|
+
// to degrade through. Falling back here would return a silent 201 while
|
|
402
|
+
// the Lead never lands in Postgres, invisible to the Inbox forever —
|
|
403
|
+
// the exact silent data loss AC-7 exists to close, merely relocated
|
|
404
|
+
// onto this never-gate surface.
|
|
405
|
+
//
|
|
406
|
+
// A DELIBERATE error response, not a bare re-raise (spec § 7's
|
|
407
|
+
// api-versioning waiver is conditioned on exactly this: "a documented
|
|
408
|
+
// error body, never a bare re-raise surfacing as an unhandled throw").
|
|
409
|
+
// `persist_failed` already maps to a 503 with a documented body
|
|
410
|
+
// (`routes/leads/index.ts`) — the SAME shape a genuine DB-outage
|
|
411
|
+
// failure (both DB and file fail) already returns, so this adds no new
|
|
412
|
+
// response shape, only a new path that reaches the existing one
|
|
413
|
+
// WITHOUT first trying — and losing time to — a file write for an
|
|
414
|
+
// error the file fallback can never fix.
|
|
415
|
+
//
|
|
416
|
+
// A GENUINE infrastructure failure (an unreachable DB, an exhausted
|
|
417
|
+
// pool, an unset DATABASE_URL) is NOT a constraint violation and still
|
|
418
|
+
// takes the file fallback below, unchanged.
|
|
419
|
+
if (isConstraintViolation(dbErr)) {
|
|
420
|
+
const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
error: { kind: "persist_failed", message: `db: ${dbMsg}` },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
268
427
|
// 5. File fallback
|
|
269
428
|
// Include a UUID suffix so concurrent fallbacks produce unique IDs even
|
|
270
429
|
// within the same millisecond. Format still matches /^[a-z0-9_-]{8,64}$/i.
|
|
@@ -331,7 +490,7 @@ export async function routeLead(
|
|
|
331
490
|
if (skipEmail) {
|
|
332
491
|
emailResult = { skipped: true, reason: skipReason ?? "skipped" };
|
|
333
492
|
} else {
|
|
334
|
-
emailResult = await sendEmail(input, id!);
|
|
493
|
+
emailResult = await sendEmail(input, id!, persisted!);
|
|
335
494
|
}
|
|
336
495
|
|
|
337
496
|
return { ok: true, id: id!, persisted, email: emailResult };
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file leads-inbox.ts
|
|
3
|
+
* @description The owner-side READ over captured leads (#5678) — the other
|
|
4
|
+
* half of `lead-routing.ts`, which only ever wrote them.
|
|
5
|
+
*
|
|
6
|
+
* Scope is resolved server-side and never supplied by the caller: the reader's
|
|
7
|
+
* identity is `request.userId`, their workspaces come from
|
|
8
|
+
* `owner-workspaces.ts`'s `resolveOwnerWorkspaces` — the ONE site→workspace
|
|
9
|
+
* resolution in this app, shared with `lead-promotion.ts` — and every query
|
|
10
|
+
* filters `siteId ∈ workspaceIds`.
|
|
11
|
+
* There is no site/org parameter to widen, so cross-tenant enumeration is
|
|
12
|
+
* structurally impossible rather than merely checked — the same posture
|
|
13
|
+
* `routes/waitlist-experiments/list.ts` takes.
|
|
14
|
+
*
|
|
15
|
+
* The scope is the UNION of every workspace the caller owns or administers,
|
|
16
|
+
* across every organization they belong to — not one active tenant, the way
|
|
17
|
+
* `waitlist-dashboard.ts` scopes to `tenant.workspaceId`. That difference is
|
|
18
|
+
* deliberate and structural: this route registers outside the tenant scope
|
|
19
|
+
* (it must work on a build with no multi-tenant module), so there is no
|
|
20
|
+
* active tenant to scope to. `Membership` is an organization-level grant, so
|
|
21
|
+
* the union stays inside what the caller may already see. When a workspace
|
|
22
|
+
* switcher reaches this surface, it is the thing that will narrow it.
|
|
23
|
+
*
|
|
24
|
+
* ## What it takes for this to return anything
|
|
25
|
+
*
|
|
26
|
+
* Two preconditions, neither of which this file can assert:
|
|
27
|
+
*
|
|
28
|
+
* 1. `resolveOwnerWorkspaces` must yield a workspace. On a build with no
|
|
29
|
+
* `multi-tenant` module it yields none — see its own header, and #5677,
|
|
30
|
+
* which made that a deliberate scope limit rather than a statement that
|
|
31
|
+
* there is nothing to resolve.
|
|
32
|
+
* 2. The `siteId` the leads were WRITTEN under must equal a workspace id,
|
|
33
|
+
* and there are TWO independent sources of it — both must agree with the
|
|
34
|
+
* workspace, and neither is `currentSiteId()` on the primary path:
|
|
35
|
+
*
|
|
36
|
+
* - The three POST capture routes take `siteId` from the REQUEST BODY
|
|
37
|
+
* (`routes/leads/index.ts`), which the capture sections declare in
|
|
38
|
+
* content and default to `"default"` (`ContactForm`, `Newsletter`,
|
|
39
|
+
* `StepFlow` — `ext.siteId ?? "default"`). Neither
|
|
40
|
+
* `routes/leads/index.ts` nor `lead-routing.ts` calls
|
|
41
|
+
* `currentSiteId()` at all.
|
|
42
|
+
* - The recommender's capture path (`routes/flows/recommender.ts`) is
|
|
43
|
+
* the one that DOES use `currentSiteId()` (`lib/site.ts`,
|
|
44
|
+
* `AILK_SITE_ID ?? "default"`).
|
|
45
|
+
*
|
|
46
|
+
* A workspace id is `wksp_<uuidv7>`, so `siteId ∈ workspaceIds` only
|
|
47
|
+
* matches when the content-declared value — and, for recommender leads,
|
|
48
|
+
* `AILK_SITE_ID` — is set to it. This is the same convention
|
|
49
|
+
* `waitlist-dashboard.ts` already relies on ("owner scope =
|
|
50
|
+
* `siteId == tenant.workspaceId`"); it is a deployment and content fact,
|
|
51
|
+
* not something either resolver can supply. Naming only `AILK_SITE_ID`
|
|
52
|
+
* here would send an operator to the wrong knob for every contact-form,
|
|
53
|
+
* newsletter and callback lead (PR #5705 security re-review, finding A).
|
|
54
|
+
*
|
|
55
|
+
* Both failures are EMPTY, never wide: the inbox shows no leads rather than
|
|
56
|
+
* someone else's. Raised by the PR #5705 security review, finding 2.
|
|
57
|
+
*
|
|
58
|
+
* PII boundary: this service returns lead PII by design, so it returns the
|
|
59
|
+
* NARROWEST row the inbox view renders — `id`, `name`, `email`, `source`,
|
|
60
|
+
* `createdAt` — and nothing else. `payload`'s remaining free-form keys
|
|
61
|
+
* (message, phone, and whatever else a site's form declares), `siteId`,
|
|
62
|
+
* `promotedUserId` and `promotedAt` are never selected out of the row that
|
|
63
|
+
* leaves this file, and the one value lifted out of `payload` is truncated
|
|
64
|
+
* (see `MAX_NAME_LENGTH`). Logs carry no lead field at all.
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
import type { Prisma } from "@working-theory/database";
|
|
68
|
+
import type { Id } from "@working-theory/ids";
|
|
69
|
+
|
|
70
|
+
import { prisma } from "../lib/prisma.js";
|
|
71
|
+
|
|
72
|
+
import { resolveOwnerWorkspaces } from "./owner-workspaces.js";
|
|
73
|
+
|
|
74
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** One row of the inbox — exactly the five values the table renders. */
|
|
77
|
+
export type LeadInboxRow = {
|
|
78
|
+
/** The lead id: the row key, and the cursor for the page after this one. */
|
|
79
|
+
id: string;
|
|
80
|
+
/**
|
|
81
|
+
* The contact's name, lifted from `payload.name` when the site's form
|
|
82
|
+
* captured one. `null` otherwise — `payload` is a free-form record
|
|
83
|
+
* (`boundedLeadPayload`), so a site whose form declares no name field has
|
|
84
|
+
* none to show.
|
|
85
|
+
*/
|
|
86
|
+
name: string | null;
|
|
87
|
+
email: string;
|
|
88
|
+
/** Which capture produced the lead (`"web"`, `"conversational-intake"`, …). */
|
|
89
|
+
source: string;
|
|
90
|
+
/** ISO 8601. */
|
|
91
|
+
createdAt: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type LeadInboxPage = {
|
|
95
|
+
items: LeadInboxRow[];
|
|
96
|
+
/** The `cursor` for the next page, or `null` when this is the last one. */
|
|
97
|
+
nextCursor: string | null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export type ListLeadsErrorKind = "invalid_cursor" | "internal_error";
|
|
101
|
+
export type ListLeadsResult =
|
|
102
|
+
| { ok: true; value: LeadInboxPage }
|
|
103
|
+
| { ok: false; error: { kind: ListLeadsErrorKind; message: string } };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Reads require `owner` or `admin` in the organization the workspace belongs
|
|
107
|
+
* to — the inbox exposes contact PII, so membership alone is not
|
|
108
|
+
* authorization. Same floor, and the same reason, as
|
|
109
|
+
* `WAITLIST_DASHBOARD_ROLES` (`waitlist-dashboard.ts`); declared here rather
|
|
110
|
+
* than imported because that file is a `signal-collectors` slice the strip
|
|
111
|
+
* removes and this one ships at every level.
|
|
112
|
+
*
|
|
113
|
+
* Exported so `inbox-messages.ts` (#6204's tenant-scoped read/write, moved
|
|
114
|
+
* off this base file's own dependency edge — see that file's header) enforces
|
|
115
|
+
* the SAME floor rather than a copy.
|
|
116
|
+
*/
|
|
117
|
+
export const LEAD_INBOX_ROLES: ReadonlySet<string> = new Set(["owner", "admin"]);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The ONLY columns read out of the Lead table on this path.
|
|
121
|
+
*
|
|
122
|
+
* `payload` is here because Prisma cannot `select` a JSON sub-path and
|
|
123
|
+
* `name` lives inside it — so the whole blob crosses the DB→API boundary for
|
|
124
|
+
* each of the `limit + 1` rows and is narrowed in this process, not by the
|
|
125
|
+
* query. Nothing but `payload.name` leaves this file (see `readName`), but the
|
|
126
|
+
* READ is the full record: the memory cost of a page is `(limit + 1) ×
|
|
127
|
+
* payload size`, independent of what is returned. Bounded above by Fastify's
|
|
128
|
+
* 1 MiB body limit on the write side and by `MAX_LIMIT` on this one.
|
|
129
|
+
*/
|
|
130
|
+
const LEAD_INBOX_SELECT = {
|
|
131
|
+
id: true,
|
|
132
|
+
email: true,
|
|
133
|
+
source: true,
|
|
134
|
+
payload: true,
|
|
135
|
+
createdAt: true,
|
|
136
|
+
} as const satisfies Prisma.LeadSelect;
|
|
137
|
+
|
|
138
|
+
// ─── Row shaping ──────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The rendered `name` is truncated to this many characters. The write side
|
|
142
|
+
* bounds `payload` at 64 KEYS and nothing else (`boundedLeadPayload`,
|
|
143
|
+
* `packages/validation/src/schemas/leads.ts`) — no per-value length — so a
|
|
144
|
+
* `name` arrives with only Fastify's 1 MiB body limit above it, while every
|
|
145
|
+
* other rendered field is bounded at the schema (`email` 254, `source` 64).
|
|
146
|
+
*
|
|
147
|
+
* That gap is reachable by an anonymous caller: `siteId` is content-declared
|
|
148
|
+
* and shipped to the browser by the capture forms, `POST /v1/leads/submit`
|
|
149
|
+
* performs no ownership check on it, and the leads-scope rate limit keys on
|
|
150
|
+
* `req.ip` under instance-wide `trustProxy` (which `routes/leads/index.ts`
|
|
151
|
+
* documents as attacker-influenceable). Fifty-one ~900 KB names would
|
|
152
|
+
* otherwise be a ~45 MB response — and, because the order is `createdAt desc`
|
|
153
|
+
* and this PR ships no delete surface, permanently page one. Truncating here
|
|
154
|
+
* is what makes the inbox's size a function of the page size rather than of
|
|
155
|
+
* what a stranger typed. Raised by the PR #5705 security review, finding 1.
|
|
156
|
+
*
|
|
157
|
+
* 200 is the same order of magnitude as `callbackSchema`'s own declared
|
|
158
|
+
* `name` bound (128) with room for a long real one. Bounding `payload`'s
|
|
159
|
+
* string VALUES at the write boundary would fix this for every future reader,
|
|
160
|
+
* but that changes what the three POST routes accept, which this issue's
|
|
161
|
+
* scope fence forbids — so the bound lives on the read.
|
|
162
|
+
*/
|
|
163
|
+
const MAX_NAME_LENGTH = 200;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* `payload` is JSON of a shape the site's own form decides, so reading one
|
|
167
|
+
* key out of it is a boundary read: anything that is not a string is `null`,
|
|
168
|
+
* and a string is truncated (see `MAX_NAME_LENGTH`).
|
|
169
|
+
*
|
|
170
|
+
* Exported so `inbox-messages.ts` reuses this exact narrowing — never a copy
|
|
171
|
+
* (see that file's header for why the shaping stays here).
|
|
172
|
+
*/
|
|
173
|
+
export function readName(payload: Prisma.JsonValue): string | null {
|
|
174
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const name = (payload as Record<string, unknown>)["name"];
|
|
178
|
+
return typeof name === "string" ? name.slice(0, MAX_NAME_LENGTH) : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* List the leads the caller's own sites captured, newest first.
|
|
185
|
+
*
|
|
186
|
+
* Pagination is the cursor shape `waitlist-experiments/signups` already
|
|
187
|
+
* established in this codebase: `(createdAt desc, id desc)`, `take: limit + 1`
|
|
188
|
+
* to decide `hasMore`, and a `cursor` that is the previous page's last row id.
|
|
189
|
+
* A supplied cursor that does not resolve WITHIN the caller's scope is a
|
|
190
|
+
* client input error (`invalid_cursor` → 400), never a silent fall back to
|
|
191
|
+
* page 1 at THIS layer — the web inbox re-reads page one when a URL-supplied
|
|
192
|
+
* cursor 400s, which is its own rendering choice and leaves this contract
|
|
193
|
+
* intact (a foreign cursor and a nonexistent one stay indistinguishable
|
|
194
|
+
* either way) — and never a probe that distinguishes "another site's lead" from
|
|
195
|
+
* "no such lead", because the scope filter runs on the cursor lookup too.
|
|
196
|
+
*/
|
|
197
|
+
export async function listOwnedLeads(params: {
|
|
198
|
+
userId: Id<"user">;
|
|
199
|
+
cursor?: string | undefined;
|
|
200
|
+
limit: number;
|
|
201
|
+
}): Promise<ListLeadsResult> {
|
|
202
|
+
const workspaces = await resolveOwnerWorkspaces(params.userId);
|
|
203
|
+
if (!workspaces.ok) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: { kind: "internal_error", message: workspaces.error.message },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const siteIds = workspaces.value
|
|
211
|
+
.filter((workspace) => LEAD_INBOX_ROLES.has(workspace.role))
|
|
212
|
+
.map((workspace) => workspace.id);
|
|
213
|
+
|
|
214
|
+
// No workspace the caller owns or administers means no lead can match, so
|
|
215
|
+
// there is nothing to query. An empty page is the honest answer — the same
|
|
216
|
+
// zero-workspace no-op the promotion path returns.
|
|
217
|
+
if (siteIds.length === 0) return { ok: true, value: { items: [], nextCursor: null } };
|
|
218
|
+
|
|
219
|
+
let cursorLead: { createdAt: Date; id: string } | undefined;
|
|
220
|
+
if (params.cursor) {
|
|
221
|
+
cursorLead =
|
|
222
|
+
(await prisma.lead.findFirst({
|
|
223
|
+
where: { id: params.cursor, siteId: { in: siteIds } },
|
|
224
|
+
select: { id: true, createdAt: true },
|
|
225
|
+
})) ?? undefined;
|
|
226
|
+
if (!cursorLead) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
error: {
|
|
230
|
+
kind: "invalid_cursor",
|
|
231
|
+
message: "cursor does not resolve within this scope",
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const rows = await prisma.lead.findMany({
|
|
238
|
+
where: {
|
|
239
|
+
siteId: { in: siteIds },
|
|
240
|
+
...(cursorLead
|
|
241
|
+
? {
|
|
242
|
+
OR: [
|
|
243
|
+
{ createdAt: { lt: cursorLead.createdAt } },
|
|
244
|
+
{ createdAt: cursorLead.createdAt, id: { lt: cursorLead.id } },
|
|
245
|
+
],
|
|
246
|
+
}
|
|
247
|
+
: {}),
|
|
248
|
+
},
|
|
249
|
+
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
|
250
|
+
take: params.limit + 1,
|
|
251
|
+
select: LEAD_INBOX_SELECT,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const hasMore = rows.length > params.limit;
|
|
255
|
+
const page = hasMore ? rows.slice(0, params.limit) : rows;
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
ok: true,
|
|
259
|
+
value: {
|
|
260
|
+
items: page.map((lead) => ({
|
|
261
|
+
id: lead.id,
|
|
262
|
+
name: readName(lead.payload),
|
|
263
|
+
email: lead.email,
|
|
264
|
+
source: lead.source,
|
|
265
|
+
createdAt: lead.createdAt.toISOString(),
|
|
266
|
+
})),
|
|
267
|
+
nextCursor: hasMore ? (page[page.length - 1]?.id ?? null) : null,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// #6204's `listUnhandledMessages` / `markMessageHandled` — the tenant-scoped
|
|
273
|
+
// "Needs-you" read/write pair over `tenantDb(tenant).leads` — moved OFF this
|
|
274
|
+
// file to `./inbox-messages.js`. This file is a BASE-tier file every scaffold
|
|
275
|
+
// ships (this header's own claim); `tenantDb` (`../lib/tenant-db.js`) is a
|
|
276
|
+
// `multi-tenant`-slice file, and the paid-module exclusion closure
|
|
277
|
+
// (`packages/create-ailk/src/lib/import-graph.ts`) is backward — a base file
|
|
278
|
+
// that imports a paid seed is ITSELF added to the exclusion set, and so is
|
|
279
|
+
// everything that imports IT. Importing `tenantDb` from here once already
|
|
280
|
+
// pulled `server.ts`/`cli.ts`/`index.ts`/`vercel-handler.ts` out of a keyless
|
|
281
|
+
// scaffold (caught by `__tests__/paid-module-exclusions.test.ts`, #5678's own
|
|
282
|
+
// cascade recurring a third time). `readName()` and `LEAD_INBOX_ROLES` above
|
|
283
|
+
// stay here (both exported) and `inbox-messages.ts` imports them — the row
|
|
284
|
+
// shape and the role floor are the SAME constants, never a copy.
|
|
@@ -42,11 +42,18 @@ import {
|
|
|
42
42
|
} from "@working-theory/validation";
|
|
43
43
|
|
|
44
44
|
import { prisma } from "../lib/prisma.js";
|
|
45
|
-
import {
|
|
45
|
+
import { resolveSiteWorkspaceId } from "../lib/site.js";
|
|
46
46
|
|
|
47
47
|
export async function listingConfigFor(siteId: string): Promise<SiteListingConfig> {
|
|
48
|
+
// #6257 — every caller hands this function an ALREADY-RESOLVED workspace id
|
|
49
|
+
// (a `ProjectListing` row's own `siteId`, an authenticated tenant's
|
|
50
|
+
// `workspaceId`, or `siteIdForPublicKey`'s return below), so `workspaceId`
|
|
51
|
+
// rides beside the unique `siteId` selector rather than being resolved a
|
|
52
|
+
// second time. `site_listing_configs_workspace_site_agree` makes the pair
|
|
53
|
+
// redundant by construction — which is the point: the tenant scope is now
|
|
54
|
+
// something the QUERY states, not something the caller is trusted to have.
|
|
48
55
|
const row = await prisma.siteListingConfig.findUnique({
|
|
49
|
-
where: { siteId },
|
|
56
|
+
where: { siteId, workspaceId: siteId },
|
|
50
57
|
select: { data: true },
|
|
51
58
|
});
|
|
52
59
|
if (!row) return SITE_LISTING_CONFIG_DEFAULTS;
|
|
@@ -132,14 +139,18 @@ function warnOnDroppedField(
|
|
|
132
139
|
* 1. A `SiteListingConfig` row whose `publicKey` matches. This is the
|
|
133
140
|
* multi-tenant path and the only one that can name a site other than the
|
|
134
141
|
* deployment's own.
|
|
135
|
-
* 2. `AILK_LISTING_PUBLIC_KEY`, matched against the env, resolving to
|
|
136
|
-
* `currentSiteId()
|
|
137
|
-
* `
|
|
138
|
-
*
|
|
139
|
-
*
|
|
142
|
+
* 2. `AILK_LISTING_PUBLIC_KEY`, matched against the env, resolving to the
|
|
143
|
+
* RESOLVED workspace id for `currentSiteId()` (#6254 AC-6/AC-10 —
|
|
144
|
+
* `resolveSiteWorkspaceId()`, not the raw alias: `project_listings`
|
|
145
|
+
* now rejects a `site_id` that disagrees with `workspace_id`,
|
|
146
|
+
* database/migrations/20260922120000_tenancy_fence). This is the
|
|
147
|
+
* SINGLE-TENANT path: the default `create-ailk` scaffold is one
|
|
148
|
+
* customer per deployment and has no config row, so requiring one
|
|
149
|
+
* would mean a site could not accept an application until an operator
|
|
150
|
+
* wrote a database row.
|
|
140
151
|
*
|
|
141
152
|
* An unmatched key resolves to `null` and the route answers 400. It does NOT
|
|
142
|
-
* fall through to
|
|
153
|
+
* fall through to the env path: a typo would otherwise silently file one
|
|
143
154
|
* site's applications against another.
|
|
144
155
|
*
|
|
145
156
|
* The env comparison is a plain `===` on an opaque public identifier — there is
|
|
@@ -147,6 +158,13 @@ function warnOnDroppedField(
|
|
|
147
158
|
* would imply a guarantee this value does not carry.
|
|
148
159
|
*/
|
|
149
160
|
export async function siteIdForPublicKey(publicKey: string): Promise<string | null> {
|
|
161
|
+
// fence: global-key — `SiteListingConfig.publicKey`, a site's own public
|
|
162
|
+
// listing key, unique across every workspace. This is the query that
|
|
163
|
+
// RESOLVES the tenant: it is handed nothing but the key, and a workspace
|
|
164
|
+
// term here would have to come from the very answer it is computing. The
|
|
165
|
+
// key is not a credential (it ships in browser JavaScript by design, #5039)
|
|
166
|
+
// and holding it grants only the ability to open a draft; an unmatched key
|
|
167
|
+
// returns `null` and the route answers 400 rather than falling through.
|
|
150
168
|
const row = await prisma.siteListingConfig.findUnique({
|
|
151
169
|
where: { publicKey },
|
|
152
170
|
select: { siteId: true },
|
|
@@ -154,7 +172,7 @@ export async function siteIdForPublicKey(publicKey: string): Promise<string | nu
|
|
|
154
172
|
if (row) return row.siteId;
|
|
155
173
|
|
|
156
174
|
const envKey = process.env["AILK_LISTING_PUBLIC_KEY"];
|
|
157
|
-
if (envKey && envKey === publicKey) return
|
|
175
|
+
if (envKey && envKey === publicKey) return resolveSiteWorkspaceId();
|
|
158
176
|
|
|
159
177
|
return null;
|
|
160
178
|
}
|