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,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file leads-inbox.test.ts
|
|
3
|
+
* @description Unit tests for the leads-inbox read service (#5678).
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing case is ISOLATION: this endpoint returns contact PII, so
|
|
6
|
+
* the claim "a caller cannot read another site's leads" is tested, not argued.
|
|
7
|
+
*
|
|
8
|
+
* External dependency boundary mocked:
|
|
9
|
+
* - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts` manual
|
|
10
|
+
* mock (`jest.mock("../../lib/prisma.js")`).
|
|
11
|
+
*
|
|
12
|
+
* `prisma.lead.findMany` / `findFirst` are given a REALISTIC filtering
|
|
13
|
+
* implementation over an in-memory fixture "table" rather than a static
|
|
14
|
+
* `mockResolvedValue`, mirroring `lead-promotion.test.ts`. That is what makes
|
|
15
|
+
* the isolation test real: a bug that widened the `siteId` match set would
|
|
16
|
+
* surface the other site's lead in the returned page, not merely in an
|
|
17
|
+
* argument-shape assertion.
|
|
18
|
+
*
|
|
19
|
+
* `tenant-context.ts`'s `listTenancy` runs REAL — a pure read over the mocked
|
|
20
|
+
* `prisma.membership.findMany` — so these tests also exercise the
|
|
21
|
+
* "resolve through `lead-promotion.ts`'s `resolveOwnerWorkspaces`" contract
|
|
22
|
+
* rather than re-stubbing it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
26
|
+
jest.mock("../../lib/prisma.js");
|
|
27
|
+
|
|
28
|
+
import type { Id } from "@working-theory/ids";
|
|
29
|
+
|
|
30
|
+
import { prisma } from "../../lib/prisma.js";
|
|
31
|
+
import { listOwnedLeads } from "../leads-inbox";
|
|
32
|
+
|
|
33
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
const USER_ID = "user_00000000-0000-7000-8000-0000000000a1" as Id<"user">;
|
|
36
|
+
const OWN_ORG_ID = "org_00000000-0000-7000-8000-0000000000b1";
|
|
37
|
+
/** The caller's own workspace — and therefore the `siteId` of their own leads. */
|
|
38
|
+
const OWN_SITE_ID = "wksp_00000000-0000-7000-8000-0000000000c1";
|
|
39
|
+
/** A DIFFERENT organization's workspace. The caller holds no membership in it. */
|
|
40
|
+
const OTHER_SITE_ID = "wksp_00000000-0000-7000-8000-0000000000c2";
|
|
41
|
+
|
|
42
|
+
type FakeLeadRow = {
|
|
43
|
+
id: string;
|
|
44
|
+
siteId: string;
|
|
45
|
+
email: string;
|
|
46
|
+
source: string;
|
|
47
|
+
payload: unknown;
|
|
48
|
+
createdAt: Date;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Shapes `prisma.membership.findMany`'s return — what `listTenancy` reads. */
|
|
52
|
+
function membershipsWithRole(role: string) {
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
role,
|
|
56
|
+
organization: {
|
|
57
|
+
id: OWN_ORG_ID,
|
|
58
|
+
name: "Acme",
|
|
59
|
+
slug: "acme",
|
|
60
|
+
workspaces: [{ id: OWN_SITE_ID, name: "Acme HQ", slug: "acme-hq" }],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function lead(overrides: Partial<FakeLeadRow> & { id: string }): FakeLeadRow {
|
|
67
|
+
return {
|
|
68
|
+
siteId: OWN_SITE_ID,
|
|
69
|
+
email: "contact@example.com",
|
|
70
|
+
source: "web",
|
|
71
|
+
payload: { name: "Ada Lovelace", message: "please call me", phone: "555" },
|
|
72
|
+
createdAt: new Date("2026-09-15T12:00:00.000Z"),
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Wires `prisma.lead.findFirst` / `findMany` to a realistic in-memory "table"
|
|
79
|
+
* filtered by the `where` the service passes — so a row outside `siteId.in` is
|
|
80
|
+
* genuinely never returned, exactly as a real WHERE clause would exclude it.
|
|
81
|
+
*/
|
|
82
|
+
function seedLeadTable(rows: FakeLeadRow[]) {
|
|
83
|
+
const table = rows.map((r) => ({ ...r }));
|
|
84
|
+
|
|
85
|
+
type Where = {
|
|
86
|
+
siteId: { in: string[] };
|
|
87
|
+
id?: string;
|
|
88
|
+
OR?: Array<{
|
|
89
|
+
createdAt?: Date | { lt: Date };
|
|
90
|
+
id?: { lt: string };
|
|
91
|
+
}>;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const inScope = (row: FakeLeadRow, where: Where) =>
|
|
95
|
+
where.siteId.in.includes(row.siteId) &&
|
|
96
|
+
(where.id === undefined || row.id === where.id);
|
|
97
|
+
|
|
98
|
+
const afterCursor = (row: FakeLeadRow, where: Where) => {
|
|
99
|
+
if (!where.OR) return true;
|
|
100
|
+
return where.OR.some((clause) => {
|
|
101
|
+
if (clause.createdAt instanceof Date) {
|
|
102
|
+
return (
|
|
103
|
+
row.createdAt.getTime() === clause.createdAt.getTime() &&
|
|
104
|
+
clause.id !== undefined &&
|
|
105
|
+
row.id < clause.id.lt
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const lt = (clause.createdAt as { lt: Date }).lt;
|
|
109
|
+
return row.createdAt.getTime() < lt.getTime();
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
(prisma.lead.findFirst as jest.Mock).mockImplementation(
|
|
114
|
+
async (args: { where: Where }) =>
|
|
115
|
+
table.find((row) => inScope(row, args.where)) ?? null,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
(prisma.lead.findMany as jest.Mock).mockImplementation(
|
|
119
|
+
async (args: { where: Where; take: number }) =>
|
|
120
|
+
table
|
|
121
|
+
.filter((row) => inScope(row, args.where) && afterCursor(row, args.where))
|
|
122
|
+
.sort((a, b) =>
|
|
123
|
+
b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? 1 : -1),
|
|
124
|
+
)
|
|
125
|
+
.slice(0, args.take),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return table;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe("listOwnedLeads", () => {
|
|
132
|
+
// The workspace resolution is module-conditional (isModuleEnabled
|
|
133
|
+
// 'multi-tenant' — see lead-promotion.ts), so every test that expects rows
|
|
134
|
+
// needs the module explicitly enabled: AILK_ENABLED_MODULES unset means no
|
|
135
|
+
// paid modules (D-44, #5368).
|
|
136
|
+
const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
jest.clearAllMocks();
|
|
140
|
+
process.env.AILK_ENABLED_MODULES = "multi-tenant";
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterAll(() => {
|
|
144
|
+
if (priorEnabledModules === undefined) {
|
|
145
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
146
|
+
} else {
|
|
147
|
+
process.env.AILK_ENABLED_MODULES = priorEnabledModules;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── ISOLATION (load-bearing) ───────────────────────────────────────────────
|
|
152
|
+
it("never returns a lead whose siteId belongs to another site", async () => {
|
|
153
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
154
|
+
membershipsWithRole("owner"), // the caller belongs ONLY to OWN_ORG_ID
|
|
155
|
+
);
|
|
156
|
+
seedLeadTable([
|
|
157
|
+
lead({
|
|
158
|
+
id: "lead_own_1",
|
|
159
|
+
siteId: OWN_SITE_ID,
|
|
160
|
+
email: "mine@example.com",
|
|
161
|
+
createdAt: new Date("2026-09-15T12:00:00.000Z"),
|
|
162
|
+
}),
|
|
163
|
+
lead({
|
|
164
|
+
id: "lead_other_1",
|
|
165
|
+
siteId: OTHER_SITE_ID,
|
|
166
|
+
email: "theirs@example.com",
|
|
167
|
+
createdAt: new Date("2026-09-15T13:00:00.000Z"), // NEWER — would sort first
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
172
|
+
|
|
173
|
+
expect(result.ok).toBe(true);
|
|
174
|
+
if (!result.ok) return;
|
|
175
|
+
expect(result.value.items.map((row) => row.id)).toEqual(["lead_own_1"]);
|
|
176
|
+
expect(JSON.stringify(result.value)).not.toContain("theirs@example.com");
|
|
177
|
+
expect(JSON.stringify(result.value)).not.toContain(OTHER_SITE_ID);
|
|
178
|
+
// The scope reaches the DB as a filter, not a post-filter in JS.
|
|
179
|
+
expect(prisma.lead.findMany).toHaveBeenCalledWith(
|
|
180
|
+
expect.objectContaining({ where: expect.objectContaining({ siteId: { in: [OWN_SITE_ID] } }) }),
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns an empty page when the caller belongs to no workspace at all", async () => {
|
|
185
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
|
|
186
|
+
seedLeadTable([lead({ id: "lead_other_1", siteId: OTHER_SITE_ID })]);
|
|
187
|
+
|
|
188
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
189
|
+
|
|
190
|
+
expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
|
|
191
|
+
// No workspace means no query at all — not a query with an empty `in`.
|
|
192
|
+
expect(prisma.lead.findMany).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("returns an empty page for a `member` — the role floor is owner|admin", async () => {
|
|
196
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
197
|
+
membershipsWithRole("member"),
|
|
198
|
+
);
|
|
199
|
+
seedLeadTable([lead({ id: "lead_own_1", siteId: OWN_SITE_ID })]);
|
|
200
|
+
|
|
201
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
202
|
+
|
|
203
|
+
expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
|
|
204
|
+
expect(prisma.lead.findMany).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("rejects a cursor that resolves outside the caller's scope", async () => {
|
|
208
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
209
|
+
membershipsWithRole("admin"),
|
|
210
|
+
);
|
|
211
|
+
seedLeadTable([
|
|
212
|
+
lead({ id: "lead_own_1", siteId: OWN_SITE_ID }),
|
|
213
|
+
lead({ id: "lead_other_1", siteId: OTHER_SITE_ID }),
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
const result = await listOwnedLeads({
|
|
217
|
+
userId: USER_ID,
|
|
218
|
+
cursor: "lead_other_1",
|
|
219
|
+
limit: 50,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(result).toEqual({
|
|
223
|
+
ok: false,
|
|
224
|
+
error: {
|
|
225
|
+
kind: "invalid_cursor",
|
|
226
|
+
message: "cursor does not resolve within this scope",
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
// The rejection is indistinguishable from "no such lead" — a foreign
|
|
230
|
+
// cursor is never a probe that confirms another site's row exists.
|
|
231
|
+
expect(prisma.lead.findMany).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── Row shape (the PII floor) ──────────────────────────────────────────────
|
|
235
|
+
it("returns exactly the five rendered fields — never the rest of the record", async () => {
|
|
236
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
237
|
+
membershipsWithRole("owner"),
|
|
238
|
+
);
|
|
239
|
+
seedLeadTable([
|
|
240
|
+
lead({
|
|
241
|
+
id: "lead_own_1",
|
|
242
|
+
email: "ada@example.com",
|
|
243
|
+
source: "conversational-intake",
|
|
244
|
+
payload: { name: "Ada Lovelace", message: "please call me", phone: "555-0100" },
|
|
245
|
+
}),
|
|
246
|
+
]);
|
|
247
|
+
|
|
248
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
249
|
+
|
|
250
|
+
expect(result.ok).toBe(true);
|
|
251
|
+
if (!result.ok) return;
|
|
252
|
+
expect(result.value.items).toEqual([
|
|
253
|
+
{
|
|
254
|
+
id: "lead_own_1",
|
|
255
|
+
name: "Ada Lovelace",
|
|
256
|
+
email: "ada@example.com",
|
|
257
|
+
source: "conversational-intake",
|
|
258
|
+
createdAt: "2026-09-15T12:00:00.000Z",
|
|
259
|
+
},
|
|
260
|
+
]);
|
|
261
|
+
// The rest of `payload` is never on the wire.
|
|
262
|
+
const serialized = JSON.stringify(result.value);
|
|
263
|
+
expect(serialized).not.toContain("please call me");
|
|
264
|
+
expect(serialized).not.toContain("555-0100");
|
|
265
|
+
expect(serialized).not.toContain("siteId");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("truncates an unbounded name — one anonymous write cannot size the page", async () => {
|
|
269
|
+
// PR #5705 security review, finding 1: `boundedLeadPayload` bounds the
|
|
270
|
+
// payload at 64 KEYS and nothing else, and `POST /v1/leads/submit` is
|
|
271
|
+
// anonymous with no ownership check on `siteId`, so a stranger can plant a
|
|
272
|
+
// ~1 MB `name` in the owner's inbox. Ordering is `createdAt desc` and this
|
|
273
|
+
// unit ships no delete surface, so it would sit on page one for good.
|
|
274
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
275
|
+
membershipsWithRole("owner"),
|
|
276
|
+
);
|
|
277
|
+
seedLeadTable([
|
|
278
|
+
lead({ id: "lead_own_1", payload: { name: "A".repeat(900_000) } }),
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
282
|
+
|
|
283
|
+
expect(result.ok).toBe(true);
|
|
284
|
+
if (!result.ok) return;
|
|
285
|
+
expect(result.value.items[0]?.name).toBe("A".repeat(200));
|
|
286
|
+
expect(JSON.stringify(result.value).length).toBeLessThan(1000);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("reads `name` as null when the site's form captured none", async () => {
|
|
290
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
291
|
+
membershipsWithRole("owner"),
|
|
292
|
+
);
|
|
293
|
+
seedLeadTable([lead({ id: "lead_own_1", payload: { company: "Acme" } })]);
|
|
294
|
+
|
|
295
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
296
|
+
|
|
297
|
+
expect(result.ok).toBe(true);
|
|
298
|
+
if (!result.ok) return;
|
|
299
|
+
expect(result.value.items[0]?.name).toBeNull();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ── Pagination ─────────────────────────────────────────────────────────────
|
|
303
|
+
it("pages newest first and hands back the last row's id as nextCursor", async () => {
|
|
304
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue(
|
|
305
|
+
membershipsWithRole("owner"),
|
|
306
|
+
);
|
|
307
|
+
seedLeadTable([
|
|
308
|
+
lead({ id: "lead_a", createdAt: new Date("2026-09-15T10:00:00.000Z") }),
|
|
309
|
+
lead({ id: "lead_b", createdAt: new Date("2026-09-15T11:00:00.000Z") }),
|
|
310
|
+
lead({ id: "lead_c", createdAt: new Date("2026-09-15T12:00:00.000Z") }),
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
const first = await listOwnedLeads({ userId: USER_ID, limit: 2 });
|
|
314
|
+
expect(first.ok).toBe(true);
|
|
315
|
+
if (!first.ok) return;
|
|
316
|
+
expect(first.value.items.map((row) => row.id)).toEqual(["lead_c", "lead_b"]);
|
|
317
|
+
expect(first.value.nextCursor).toBe("lead_b");
|
|
318
|
+
|
|
319
|
+
const second = await listOwnedLeads({
|
|
320
|
+
userId: USER_ID,
|
|
321
|
+
cursor: first.value.nextCursor ?? undefined,
|
|
322
|
+
limit: 2,
|
|
323
|
+
});
|
|
324
|
+
expect(second.ok).toBe(true);
|
|
325
|
+
if (!second.ok) return;
|
|
326
|
+
expect(second.value.items.map((row) => row.id)).toEqual(["lead_a"]);
|
|
327
|
+
expect(second.value.nextCursor).toBeNull();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file listing-config.test.ts
|
|
3
|
+
* @description Unit tests for the per-site project-listings config reads
|
|
4
|
+
* (#5033) under the tenancy fence (#6257, Stage 2 unit C2).
|
|
5
|
+
*
|
|
6
|
+
* This suite is NEW. `listing-config.ts`'s only coverage lived under
|
|
7
|
+
* `apps/api/src/routes/**\/__tests__/`, which this unit must not touch — so
|
|
8
|
+
* the two reads it owns are pinned here instead, at the service boundary
|
|
9
|
+
* where the fence is applied.
|
|
10
|
+
*
|
|
11
|
+
* Both reads run on the ANONYMOUS path, with no authenticated tenant:
|
|
12
|
+
*
|
|
13
|
+
* - `listingConfigFor(siteId)` is handed an ALREADY-RESOLVED workspace id
|
|
14
|
+
* by every caller (a listing row's own `siteId`, a tenant's
|
|
15
|
+
* `workspaceId`, or `siteIdForPublicKey`'s return), so its read is
|
|
16
|
+
* scoped by it.
|
|
17
|
+
* - `siteIdForPublicKey(publicKey)` is the query that RESOLVES the tenant.
|
|
18
|
+
* It is the documented global-key exemption (its call site carries the
|
|
19
|
+
* marker comment): it is keyed on a
|
|
20
|
+
* site's public listing key, which names one row across all workspaces,
|
|
21
|
+
* and a tenant term would be the very thing it exists to produce.
|
|
22
|
+
*
|
|
23
|
+
* External dependency boundaries mocked:
|
|
24
|
+
* - @working-theory/database (via moduleNameMapper → prisma-client.js mock;
|
|
25
|
+
* prisma singleton via __mocks__/prisma.ts)
|
|
26
|
+
* - @working-theory/observability logger
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
|
|
30
|
+
jest.mock("../../lib/prisma.js");
|
|
31
|
+
|
|
32
|
+
const mockLoggerWarn = jest.fn();
|
|
33
|
+
const mockLoggerError = jest.fn();
|
|
34
|
+
jest.mock("@working-theory/observability", () => ({
|
|
35
|
+
logger: {
|
|
36
|
+
info: jest.fn(),
|
|
37
|
+
warn: (...args: unknown[]) => mockLoggerWarn(...args),
|
|
38
|
+
error: (...args: unknown[]) => mockLoggerError(...args),
|
|
39
|
+
debug: jest.fn(),
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { SITE_LISTING_CONFIG_DEFAULTS } from "@working-theory/validation";
|
|
44
|
+
|
|
45
|
+
import { prisma } from "../../lib/prisma.js";
|
|
46
|
+
import { __resetSiteWorkspaceCacheForTests } from "../../lib/site.js";
|
|
47
|
+
import { listingConfigFor, siteIdForPublicKey } from "../listing-config";
|
|
48
|
+
|
|
49
|
+
const SITE_ID = "wksp_00000000-0000-7000-8000-0000000000a1";
|
|
50
|
+
const OTHER_SITE_ID = "wksp_00000000-0000-7000-8000-0000000000ff";
|
|
51
|
+
const PUBLIC_KEY = "pk_listing_abcdef";
|
|
52
|
+
|
|
53
|
+
const priorPublicKey = process.env["AILK_LISTING_PUBLIC_KEY"];
|
|
54
|
+
const priorSiteId = process.env["AILK_SITE_ID"];
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
jest.clearAllMocks();
|
|
58
|
+
__resetSiteWorkspaceCacheForTests();
|
|
59
|
+
delete process.env["AILK_LISTING_PUBLIC_KEY"];
|
|
60
|
+
delete process.env["AILK_SITE_ID"];
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(() => {
|
|
64
|
+
if (priorPublicKey === undefined) delete process.env["AILK_LISTING_PUBLIC_KEY"];
|
|
65
|
+
else process.env["AILK_LISTING_PUBLIC_KEY"] = priorPublicKey;
|
|
66
|
+
if (priorSiteId === undefined) delete process.env["AILK_SITE_ID"];
|
|
67
|
+
else process.env["AILK_SITE_ID"] = priorSiteId;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ─── listingConfigFor — the fenced read ────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe("listingConfigFor", () => {
|
|
73
|
+
it("scopes the config read by the workspace id it was handed (#6257 AC-1/AC-2)", async () => {
|
|
74
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
75
|
+
data: { maxWindowDays: 60, defaultWindowDays: 21 },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await listingConfigFor(SITE_ID);
|
|
79
|
+
|
|
80
|
+
expect(prisma.siteListingConfig.findUnique).toHaveBeenCalledTimes(1);
|
|
81
|
+
const { where } = (prisma.siteListingConfig.findUnique as jest.Mock).mock.calls[0][0];
|
|
82
|
+
expect(where.workspaceId).toBe(SITE_ID);
|
|
83
|
+
expect(where.siteId).toBe(SITE_ID);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("reads a DIFFERENT site under that site's own workspace id, never a remembered one", async () => {
|
|
87
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
88
|
+
|
|
89
|
+
await listingConfigFor(SITE_ID);
|
|
90
|
+
await listingConfigFor(OTHER_SITE_ID);
|
|
91
|
+
|
|
92
|
+
const calls = (prisma.siteListingConfig.findUnique as jest.Mock).mock.calls;
|
|
93
|
+
expect(calls[0][0].where.workspaceId).toBe(SITE_ID);
|
|
94
|
+
expect(calls[1][0].where.workspaceId).toBe(OTHER_SITE_ID);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("a site with no row gets the platform defaults", async () => {
|
|
98
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
99
|
+
|
|
100
|
+
await expect(listingConfigFor(SITE_ID)).resolves.toEqual(
|
|
101
|
+
SITE_LISTING_CONFIG_DEFAULTS,
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("a stored blob that fails its schema gets the platform defaults, logged at ERROR", async () => {
|
|
106
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
107
|
+
data: { maxWindowDays: "not-a-number" },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await expect(listingConfigFor(SITE_ID)).resolves.toEqual(
|
|
111
|
+
SITE_LISTING_CONFIG_DEFAULTS,
|
|
112
|
+
);
|
|
113
|
+
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
114
|
+
expect.objectContaining({ siteId: SITE_ID }),
|
|
115
|
+
expect.stringContaining("PLATFORM defaults"),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ─── siteIdForPublicKey — the documented global-key exemption ──────────────
|
|
121
|
+
|
|
122
|
+
describe("siteIdForPublicKey", () => {
|
|
123
|
+
it("is keyed on the public listing key ALONE — the query that resolves the tenant carries no tenant term", async () => {
|
|
124
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue({
|
|
125
|
+
siteId: SITE_ID,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await expect(siteIdForPublicKey(PUBLIC_KEY)).resolves.toBe(SITE_ID);
|
|
129
|
+
|
|
130
|
+
const { where } = (prisma.siteListingConfig.findUnique as jest.Mock).mock.calls[0][0];
|
|
131
|
+
expect(where).toEqual({ publicKey: PUBLIC_KEY });
|
|
132
|
+
expect(where.workspaceId).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("an unmatched key resolves to null rather than falling through to the env path", async () => {
|
|
136
|
+
process.env["AILK_LISTING_PUBLIC_KEY"] = "pk_env_key";
|
|
137
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
138
|
+
|
|
139
|
+
await expect(siteIdForPublicKey("pk_typo")).resolves.toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("the env single-tenant path returns the RESOLVED workspace id, never the raw alias", async () => {
|
|
143
|
+
process.env["AILK_LISTING_PUBLIC_KEY"] = PUBLIC_KEY;
|
|
144
|
+
process.env["AILK_SITE_ID"] = SITE_ID;
|
|
145
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
146
|
+
// Keyed on the REQUESTED id — a blanket resolve would make a fail-closed
|
|
147
|
+
// assertion impossible (#6374's own test convention).
|
|
148
|
+
(prisma.workspace.findUnique as jest.Mock).mockImplementation(
|
|
149
|
+
async ({ where }: { where: { id: string } }) =>
|
|
150
|
+
where.id === SITE_ID
|
|
151
|
+
? { id: SITE_ID, organizationId: "org_00000000-0000-7000-8000-000000000001" }
|
|
152
|
+
: null,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
await expect(siteIdForPublicKey(PUBLIC_KEY)).resolves.toBe(SITE_ID);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("fails closed when the env alias names no workspace", async () => {
|
|
159
|
+
process.env["AILK_LISTING_PUBLIC_KEY"] = PUBLIC_KEY;
|
|
160
|
+
process.env["AILK_SITE_ID"] = "no-such-workspace";
|
|
161
|
+
(prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
|
|
162
|
+
(prisma.workspace.findUnique as jest.Mock).mockResolvedValue(null);
|
|
163
|
+
|
|
164
|
+
await expect(siteIdForPublicKey(PUBLIC_KEY)).resolves.toBeNull();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* @file project-listings.fence.test.ts
|
|
4
|
+
*
|
|
5
|
+
* #6258 AC-6 — `attachWaitlistCounts` (`services/project-listings.ts:425-443`)
|
|
6
|
+
* is one of the nine fenced reads that gain a `workspaceId` term beside their
|
|
7
|
+
* existing `siteId` one. This suite is that read's per-`where` proof.
|
|
8
|
+
*
|
|
9
|
+
* Why a NEW file rather than an addition to an existing suite: this service's
|
|
10
|
+
* only coverage today lives under `apps/api/src/routes/project-listings/__tests__/`,
|
|
11
|
+
* a path under the advisory route floor this unit keeps its diff out of
|
|
12
|
+
* (spec § 8.3). The `where` lives in the service, so the service is where it
|
|
13
|
+
* is proven.
|
|
14
|
+
*/
|
|
15
|
+
import { listProjectListings } from "../project-listings.js";
|
|
16
|
+
|
|
17
|
+
// Boundary mocks — `project-listings.ts`'s import chain reaches the Resend
|
|
18
|
+
// client and the licence check; neither is exercised by the read under test.
|
|
19
|
+
jest.mock("@working-theory/email", () => ({
|
|
20
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
21
|
+
sendDeliverableEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.mock("@working-theory/license", () => ({
|
|
25
|
+
checkLicense: jest.fn().mockResolvedValue({
|
|
26
|
+
tier: "free",
|
|
27
|
+
plan: null,
|
|
28
|
+
features: [],
|
|
29
|
+
expiresAt: null,
|
|
30
|
+
revoked: false,
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
35
|
+
jest.mock("../../lib/prisma.js", () => ({
|
|
36
|
+
prisma: {
|
|
37
|
+
projectListing: { findMany: jest.fn() },
|
|
38
|
+
waitlistScore: { count: jest.fn() },
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import { prisma } from "../../lib/prisma.js";
|
|
43
|
+
|
|
44
|
+
const SITE = "wksp_00000000-0000-7000-8000-000000000001";
|
|
45
|
+
const EXPERIMENT_A = "ailk-founder-diagnostic";
|
|
46
|
+
const EXPERIMENT_B = "ailk-agency-waitlist";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A listing row carrying `workspaceId` equal to its `siteId` — exactly what
|
|
50
|
+
* the `project_listings_workspace_site_agree` CHECK guarantees in the
|
|
51
|
+
* database (#6258 D11).
|
|
52
|
+
*/
|
|
53
|
+
function listingRow(id: string, experimentKey: string | null): Record<string, unknown> {
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
siteId: SITE,
|
|
57
|
+
workspaceId: SITE,
|
|
58
|
+
leadId: null,
|
|
59
|
+
slug: `slug-${id}`,
|
|
60
|
+
name: "Acme",
|
|
61
|
+
headline: null,
|
|
62
|
+
oneLiner: null,
|
|
63
|
+
problem: null,
|
|
64
|
+
solution: null,
|
|
65
|
+
url: null,
|
|
66
|
+
product: null,
|
|
67
|
+
commitment: null,
|
|
68
|
+
teamComposition: null,
|
|
69
|
+
faqs: null,
|
|
70
|
+
anythingElse: null,
|
|
71
|
+
address: null,
|
|
72
|
+
addressLine1: null,
|
|
73
|
+
addressLine2: null,
|
|
74
|
+
addressCity: null,
|
|
75
|
+
addressRegion: null,
|
|
76
|
+
addressPostalCode: null,
|
|
77
|
+
addressCountry: null,
|
|
78
|
+
questions: null,
|
|
79
|
+
siteAnswers: null,
|
|
80
|
+
windowDays: null,
|
|
81
|
+
founderName: null,
|
|
82
|
+
founderFirstName: null,
|
|
83
|
+
founderLastName: null,
|
|
84
|
+
founderEmail: null,
|
|
85
|
+
founderPhone: null,
|
|
86
|
+
founderCompany: null,
|
|
87
|
+
emailVerifiedAt: null,
|
|
88
|
+
logoMarkUrl: null,
|
|
89
|
+
logoWordmarkUrl: null,
|
|
90
|
+
featured: false,
|
|
91
|
+
status: "active",
|
|
92
|
+
consentAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
93
|
+
decidedAt: null,
|
|
94
|
+
decidedBy: null,
|
|
95
|
+
issueUrl: null,
|
|
96
|
+
experimentKey,
|
|
97
|
+
windowEndsAt: new Date("2099-01-01T00:00:00.000Z"),
|
|
98
|
+
createdAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
99
|
+
updatedAt: new Date("2026-09-01T00:00:00.000Z"),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
jest.clearAllMocks();
|
|
105
|
+
(prisma.waitlistScore.count as jest.Mock).mockResolvedValue(3);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("attachWaitlistCounts — the fenced count filters on workspaceId beside siteId (#6258 AC-6)", () => {
|
|
109
|
+
it("prisma.waitlistScore.count's where carries workspaceId === the resolved scope id, by value", async () => {
|
|
110
|
+
(prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
|
|
111
|
+
listingRow("plst_a", EXPERIMENT_A),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
await listProjectListings(SITE);
|
|
115
|
+
|
|
116
|
+
expect((prisma.waitlistScore.count as jest.Mock).mock.calls[0]![0]!.where).toEqual({
|
|
117
|
+
siteId: SITE,
|
|
118
|
+
workspaceId: SITE,
|
|
119
|
+
experimentKey: EXPERIMENT_A,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("every DISTINCT experimentKey gets the same two scope terms — one count per key, no more", async () => {
|
|
124
|
+
(prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
|
|
125
|
+
listingRow("plst_a", EXPERIMENT_A),
|
|
126
|
+
listingRow("plst_b", EXPERIMENT_B),
|
|
127
|
+
listingRow("plst_c", EXPERIMENT_A),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
await listProjectListings(SITE);
|
|
131
|
+
|
|
132
|
+
expect(prisma.waitlistScore.count).toHaveBeenCalledTimes(2);
|
|
133
|
+
const wheres = (prisma.waitlistScore.count as jest.Mock).mock.calls.map(
|
|
134
|
+
(call) => call[0]!.where as Record<string, unknown>,
|
|
135
|
+
);
|
|
136
|
+
for (const where of wheres) {
|
|
137
|
+
expect(where.siteId).toBe(SITE);
|
|
138
|
+
expect(where.workspaceId).toBe(SITE);
|
|
139
|
+
}
|
|
140
|
+
expect(wheres.map((w) => w.experimentKey).sort()).toEqual(
|
|
141
|
+
[EXPERIMENT_A, EXPERIMENT_B].sort(),
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("no listing carries an experimentKey → no fenced read is issued at all", async () => {
|
|
146
|
+
(prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
|
|
147
|
+
listingRow("plst_a", null),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
await listProjectListings(SITE);
|
|
151
|
+
|
|
152
|
+
expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
});
|