create-ailk 0.7.0 → 0.9.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 +112 -10
- package/component-catalog.json +798 -49
- package/component-catalog.md +9 -6
- package/dist/add-module.d.ts +20 -0
- package/dist/add-module.js +43 -7
- package/dist/add-route.d.ts +56 -0
- package/dist/add-route.js +286 -0
- package/dist/apply-design-md.d.ts +7 -0
- package/dist/apply-design-md.js +50 -0
- package/dist/apply-design.d.ts +64 -0
- package/dist/apply-design.js +273 -0
- package/dist/claude-md.d.ts +111 -0
- package/dist/claude-md.js +334 -0
- package/dist/cli.js +114 -28
- package/dist/derive-architecture.js +41 -36
- package/dist/design-compile.d.ts +71 -0
- package/dist/design-compile.js +98 -0
- package/dist/design-package.d.ts +83 -0
- package/dist/design-package.js +468 -0
- package/dist/design-presets.d.ts +86 -0
- package/dist/design-presets.js +642 -0
- package/dist/design-scaffold.d.ts +76 -0
- package/dist/design-scaffold.js +220 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/legacy-nav-renderers.d.ts +45 -0
- package/dist/legacy-nav-renderers.js +545 -0
- package/dist/lib/auth-secret-file.d.ts +19 -0
- package/dist/lib/auth-secret-file.js +112 -0
- package/dist/lib/design-checks.d.ts +78 -0
- package/dist/lib/design-checks.js +174 -0
- package/dist/lib/extract-module-bundle.js +5 -1
- package/dist/lib/license-key-files.d.ts +9 -0
- package/dist/lib/license-key-files.js +2 -2
- package/dist/lib/module-license-gate.d.ts +89 -0
- package/dist/lib/module-license-gate.js +171 -12
- package/dist/lib/module-tier.d.ts +45 -16
- package/dist/lib/module-tier.js +76 -0
- package/dist/lib/pro-callout.js +10 -2
- package/dist/lib/render-verdict.d.ts +52 -0
- package/dist/lib/render-verdict.js +77 -0
- package/dist/lib/scaffold-render-sweep.d.ts +105 -0
- package/dist/lib/scaffold-render-sweep.js +236 -0
- package/dist/module-architecture.d.ts +19 -16
- package/dist/module-architecture.js +64 -21
- package/dist/parse-args.d.ts +88 -10
- package/dist/parse-args.js +272 -21
- package/dist/programmatic.d.ts +66 -12
- package/dist/programmatic.js +361 -34
- package/dist/site-chrome.d.ts +19 -9
- package/dist/site-chrome.js +23 -13
- package/dist/site-manifest.d.ts +25 -0
- package/dist/site-manifest.js +254 -0
- package/dist/site-roster.d.ts +41 -0
- package/dist/site-roster.js +149 -11
- package/dist/starter-grid.d.ts +232 -0
- package/dist/starter-grid.js +545 -0
- package/dist/surfaces.d.ts +41 -9
- package/dist/surfaces.js +268 -92
- package/dist/template-manifest.d.ts +65 -29
- package/dist/template-manifest.js +161 -382
- package/dist/template-names.d.ts +14 -12
- package/dist/template-names.js +13 -17
- package/package.json +5 -4
- package/site-templates/_base/content/en/Feature/feature-1.mdx +69 -0
- package/site-templates/_base/content/en/Feature/feature-2.mdx +69 -0
- package/site-templates/_base/content/en/Feature/feature-3.mdx +69 -0
- package/site-templates/_base/content/en/FeaturesList/features.mdx +91 -0
- package/site-templates/_base/content/en/GlossaryList/glossary.mdx +0 -1
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +0 -1
- package/site-templates/_base/content/en/PortfolioItem/sample-four.mdx +105 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-one.mdx +34 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-three.mdx +94 -0
- package/site-templates/_base/content/en/PortfolioItem/sample-two.mdx +45 -0
- package/site-templates/_base/content/en/PortfolioList/portfolio.mdx +169 -0
- package/site-templates/_base/content/en/PrivacyPage/privacy.mdx +15 -0
- package/site-templates/_base/content/en/UseCase/persona-1.mdx +70 -0
- package/site-templates/_base/content/en/UseCase/persona-2.mdx +70 -0
- package/site-templates/_base/content/en/UseCase/persona-3.mdx +70 -0
- package/site-templates/{minimalism-site/TEMPLATE-MANIFEST.json → aliases/minimalism-site.TEMPLATE-MANIFEST.json} +72 -40
- package/site-templates/designs/minimalism/DESIGN.md +98 -6
- package/site-templates/designs/minimalism/sample/README.md +48 -0
- package/site-templates/designs/standard/DESIGN.md +103 -6
- package/site-templates/marketing-site/DESIGN.md +103 -6
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +144 -47
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +82 -41
- package/site-templates/marketing-site-free/DESIGN.md +103 -6
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +150 -48
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +114 -67
- package/site-templates/marketing-site-free/ailk-template.json +1 -1
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +199 -0
- package/site-templates/saas-product/DESIGN.md +103 -6
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +9 -1
- package/site-templates/{simple-service → shapes/local-business-service}/TEMPLATE-MANIFEST.json +210 -81
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +356 -0
- package/site-templates/shapes/local-business-service/ailk-template.json +10 -0
- package/site-templates/{simple-service → shapes/local-business-service}/content/en/AboutPage/about.mdx +4 -4
- package/site-templates/shapes/local-business-service/content/en/HomePage/home.mdx +70 -0
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/local-business-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/{minimalism-site → shapes/local-business-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +7 -8
- package/site-templates/shapes/local-business-service/content/en/Service/first-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/Service/second-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/Service/third-job.mdx +39 -0
- package/site-templates/shapes/local-business-service/content/en/ServicesList/services.mdx +61 -0
- package/site-templates/shapes/local-business-service/provenance.json +4 -0
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +471 -0
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +354 -0
- package/site-templates/shapes/organization-service/ailk-template.json +10 -0
- package/site-templates/shapes/organization-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/shapes/organization-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/organization-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/shapes/organization-service/content/en/PurchasePolicyPage/purchase-policy.mdx +123 -0
- package/site-templates/{minimalism-site → shapes/organization-service}/content/en/ServicesList/services.mdx +3 -4
- package/site-templates/shapes/organization-service/provenance.json +4 -0
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +478 -0
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +353 -0
- package/site-templates/shapes/person-service/ailk-template.json +10 -0
- package/site-templates/shapes/person-service/content/_site.mdx +85 -0
- package/site-templates/shapes/person-service/content/en/AboutPage/about.mdx +39 -0
- package/site-templates/shapes/person-service/content/en/HomePage/home.mdx +97 -0
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +59 -0
- package/site-templates/shapes/person-service/content/en/PortfolioList/portfolio.mdx +171 -0
- package/site-templates/{simple-service → shapes/person-service}/content/en/PurchasePolicyPage/purchase-policy.mdx +10 -11
- package/site-templates/shapes/person-service/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/shapes/person-service/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/shapes/person-service/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/{simple-service → shapes/person-service}/content/en/ServicesList/services.mdx +16 -16
- package/site-templates/shapes/person-service/provenance.json +4 -0
- package/templates/.claude/rules/architecture.md +7 -0
- package/templates/.claude/skills/README.md +5 -3
- package/templates/.claude/skills/add-auth/references/env-vars.md +22 -15
- package/templates/.claude/skills/add-auth/references/oauth-app-setup.md +3 -2
- package/templates/.claude/skills/add-page/SKILL.md +241 -238
- package/templates/.claude/skills/add-page/references/page-types.md +85 -72
- package/templates/.claude/skills/add-page/scripts/site-pages.mjs +87 -0
- package/templates/.claude/skills/add-page/templates/comparison-page.mdx.tmpl +99 -0
- package/templates/.claude/skills/add-page/templates/content.mdx.tmpl +8 -18
- package/templates/.claude/skills/add-page/templates/flow-landing-page.mdx.tmpl +43 -0
- package/templates/.claude/skills/add-page/templates/report-page.mdx.tmpl +52 -0
- package/templates/.claude/skills/add-page/templates/reports-list.mdx.tmpl +38 -0
- package/templates/.claude/skills/check-site/SKILL.md +23 -0
- package/templates/.claude/skills/fill-page/SKILL.md +26 -7
- package/templates/.claude/skills/fill-page/profiles/plumbing.yaml +1 -1
- package/templates/.claude/skills/setup-database/SKILL.md +156 -0
- package/templates/.claude/skills/setup-database/scripts/verify-inbox.ts +154 -0
- package/templates/.claude/skills/setup-deploy/SKILL.md +64 -32
- package/templates/.claude/skills/setup-deploy/references/env-vars-template.md +44 -36
- package/templates/.claude/skills/setup-deploy/references/runbook-template.md +58 -102
- package/templates/.claude/skills/update-page/SKILL.md +117 -0
- package/templates/.claude/skills/update-page/scripts/update-page.ts +640 -0
- package/templates/.claude/skills/update-page/templates/redirects.mjs.tmpl +31 -0
- package/templates/.claude/skills/upgrade-plan/SKILL.md +12 -8
- package/templates/.github/workflows/ci.yml +4 -0
- package/templates/.prettierignore +1 -0
- package/templates/CLAUDE.md +146 -103
- package/templates/DESIGN.md +103 -6
- package/templates/README.md +63 -43
- package/templates/apps/api/.env.example +2 -1
- package/templates/apps/api/CLAUDE.md +3 -2
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +2 -0
- package/templates/apps/api/src/__tests__/server.test.ts +116 -0
- package/templates/apps/api/src/config/__tests__/index.test.ts +115 -14
- package/templates/apps/api/src/config/index.ts +35 -16
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +30 -0
- package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +96 -0
- package/templates/apps/api/src/lib/lead-capture-store.ts +12 -0
- package/templates/apps/api/src/lib/lead-delivery.ts +52 -0
- package/templates/apps/api/src/lib/stripe.ts +9 -0
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +140 -0
- package/templates/apps/api/src/middleware/auth.ts +54 -0
- package/templates/apps/api/src/openapi/spec.ts +63 -12
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +5 -2
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +628 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +222 -24
- package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +88 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +100 -0
- package/templates/apps/api/src/routes/leads/index.ts +46 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +5 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +521 -20
- package/templates/apps/api/src/routes/webhooks/stripe.ts +124 -32
- package/templates/apps/api/src/server.ts +35 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +194 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +124 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +90 -15
- package/templates/apps/api/src/services/checkout-buyer.ts +72 -0
- package/templates/apps/api/src/services/lead-routing.ts +140 -30
- package/templates/apps/api/src/services/leads-inbox.ts +48 -11
- package/templates/apps/api/src/services/revalidation.ts +60 -15
- package/templates/apps/mcp/__tests__/parity.test.ts +5 -4
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +30 -24
- package/templates/apps/mcp/src/tools/create_project.ts +21 -6
- package/templates/apps/mcp/src/tools/purchase.ts +4 -2
- package/templates/apps/web/.env.example +13 -0
- package/templates/apps/web/CLAUDE.md +22 -28
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +1 -1
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +17 -6
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +110 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/alternatives/__tests__/page.test.tsx +161 -0
- package/templates/apps/web/app/[locale]/alternatives/page.tsx +104 -0
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +14 -6
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +10 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +32 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +129 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +227 -84
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +50 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +73 -20
- package/templates/apps/web/app/[locale]/contact/page.tsx +105 -15
- package/templates/apps/web/app/[locale]/contact/site-contact-email.ts +54 -0
- package/templates/apps/web/app/[locale]/features/[slug]/page.tsx +64 -0
- package/templates/apps/web/app/[locale]/features/page.tsx +52 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/[slug]/page.tsx +21 -5
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +73 -0
- package/templates/apps/web/app/[locale]/{use-cases → for}/page.tsx +8 -7
- package/templates/apps/web/app/[locale]/glossary/page.tsx +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +65 -17
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +75 -0
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +163 -0
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +66 -0
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +106 -14
- package/templates/apps/web/app/[locale]/portfolio/[slug]/page.tsx +59 -0
- package/templates/apps/web/app/[locale]/{book-a-call → portfolio}/page.tsx +12 -13
- package/templates/apps/web/app/[locale]/vs/[slug]/__tests__/page.test.tsx +112 -0
- package/templates/apps/web/app/[locale]/vs/[slug]/page.tsx +70 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +236 -44
- package/templates/apps/web/app/api/revalidate/route.ts +78 -36
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +79 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +104 -11
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +21 -4
- package/templates/apps/web/lib/locale-guard.ts +18 -0
- package/templates/apps/web/lib/meet-placeholder.ts +5 -0
- package/templates/apps/web/lib/nav-config.ts +50 -37
- package/templates/apps/web/lib/site-brand.tsx +13 -9
- package/templates/apps/web/lib/site-identity.ts +14 -0
- package/templates/apps/web/lib/site-logo.tsx +36 -0
- package/templates/apps/web/lib/site-theme.ts +46 -26
- package/templates/apps/web/messages/en.json +45 -5
- package/templates/apps/web/middleware.ts +28 -4
- package/templates/content/_site.mdx +4 -2
- package/templates/content/en/BlogList/blog.mdx +37 -16
- package/templates/content/en/BookCall/book-a-call.mdx +80 -0
- package/templates/content/en/BookCall/onsite-estimate.mdx +56 -0
- package/templates/content/en/BookCall/onsite-service.mdx +56 -0
- package/templates/content/en/BookCall/start-project.mdx +56 -0
- package/templates/content/en/ContactPage/contact.mdx +43 -34
- package/templates/content/en/HomePage/home.mdx +10 -22
- package/templates/content/en/LandingPage/meet.mdx +59 -0
- package/templates/content/en/PurchasePolicyPage/purchase-policy.mdx +0 -1
- package/templates/content/en/UseCasesList/{use-cases.mdx → for.mdx} +19 -18
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- package/templates/package.json +2 -1
- package/templates/tools/check-claude-md.ts +128 -0
- package/versions.json +33 -33
- package/site-templates/_base/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/_base/content/en/UseCase/first-use-case.mdx +0 -23
- package/site-templates/_base/content/en/UseCase/second-use-case.mdx +0 -23
- package/site-templates/_base/content/en/UseCase/third-use-case.mdx +0 -23
- package/site-templates/minimalism-site/DESIGN.md +0 -162
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +0 -470
- package/site-templates/minimalism-site/ailk-template.json +0 -23
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +0 -110
- package/site-templates/minimalism-site/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +0 -34
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +0 -57
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +0 -77
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +0 -73
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +0 -52
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +0 -52
- package/site-templates/minimalism-site/flows/referral.flow.json +0 -71
- package/site-templates/minimalism-site/provenance.json +0 -4
- package/site-templates/simple-service/DESIGN.md +0 -162
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +0 -333
- package/site-templates/simple-service/ailk-template.json +0 -11
- package/site-templates/simple-service/content/en/BookCall/book-call.mdx +0 -53
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +0 -32
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +0 -56
- package/site-templates/simple-service/content/en/HomePage/home.mdx +0 -85
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +0 -40
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +0 -40
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +0 -40
- package/site-templates/simple-service/provenance.json +0 -4
- package/templates/.claude/skills/add-page/templates/route.tsx.tmpl +0 -28
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +0 -211
- package/templates/apps/web/app/[locale]/contact/__tests__/ContactForm.test.tsx +0 -96
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +0 -72
- package/templates/content/en/SoftwareProduct/product.mdx +0 -175
- /package/site-templates/{minimalism-site → designs/minimalism/sample}/content/en/HomePage/home.mdx +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-4.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-5.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/avatar-placeholder-6.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/hero-backdrop.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-1.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-2.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/logo-placeholder-3.svg +0 -0
- /package/site-templates/{minimalism-site/assets → designs/minimalism/sample/public}/wordmark-placeholder.svg +0 -0
- /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/first-engagement.mdx +0 -0
- /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/second-engagement.mdx +0 -0
- /package/site-templates/{minimalism-site → shapes/organization-service}/content/en/Service/third-engagement.mdx +0 -0
|
@@ -34,16 +34,20 @@
|
|
|
34
34
|
* Billing upsert (customer.created / checkout.session.completed):
|
|
35
35
|
* Billing-entity routing (org-scoped billing, #3601, spec D2): a checkout
|
|
36
36
|
* carrying metadata.organizationId links the Organization (multi-tenant
|
|
37
|
-
* module); absent → the
|
|
37
|
+
* module); absent → the User path (single-tenant).
|
|
38
38
|
*
|
|
39
39
|
* Organization path (metadata.organizationId present):
|
|
40
40
|
* 1. Typed composite match already pointing at this org → idempotent no-op
|
|
41
41
|
* 2. Else link the target Organization (by id) billing fields
|
|
42
42
|
*
|
|
43
|
-
* User path (no organizationId
|
|
43
|
+
* User path (no organizationId):
|
|
44
44
|
* 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customer)
|
|
45
|
-
* 2.
|
|
46
|
-
*
|
|
45
|
+
* 2. Verified user signal: metadata.ailk_user_id →
|
|
46
|
+
* User.findUnique({ where: { id } }) → bind only when the row's
|
|
47
|
+
* billingCustomerId is empty; otherwise log billing_identity_conflict
|
|
48
|
+
* and write nothing (#6113). No signal → log billing_identity_unverified,
|
|
49
|
+
* write nothing (#6852) — the payer-typed Checkout email never binds.
|
|
50
|
+
* 3. Signal names no User → warn billing_identity_unknown_user; no row created
|
|
47
51
|
*
|
|
48
52
|
* Recommender purchase stamp (checkout.session.completed / .expired, #4838):
|
|
49
53
|
* A session carrying metadata.flowCheckoutId (the FlowCheckout buyer row
|
|
@@ -275,7 +279,7 @@ export const stripeWebhookHandler = async (
|
|
|
275
279
|
// Billing-entity routing (spec D2): a multi-tenant checkout stamps the
|
|
276
280
|
// target Organization id in metadata.organizationId (an individual → their
|
|
277
281
|
// sentinel default org). Present → link the Organization; absent → the
|
|
278
|
-
//
|
|
282
|
+
// User path (single-tenant, degrade-clean).
|
|
279
283
|
const organizationId = customer.metadata?.["organizationId"] ?? null;
|
|
280
284
|
if (organizationId) {
|
|
281
285
|
await upsertOrgBillingIdentity(request, {
|
|
@@ -286,7 +290,8 @@ export const stripeWebhookHandler = async (
|
|
|
286
290
|
} else {
|
|
287
291
|
await upsertBillingIdentity(request, {
|
|
288
292
|
customerId: customer.id,
|
|
289
|
-
|
|
293
|
+
userId: signalOf(customer.metadata),
|
|
294
|
+
eventType: event.type,
|
|
290
295
|
eventIdPrefix,
|
|
291
296
|
});
|
|
292
297
|
}
|
|
@@ -306,7 +311,7 @@ export const stripeWebhookHandler = async (
|
|
|
306
311
|
};
|
|
307
312
|
|
|
308
313
|
// Billing-entity routing (spec D2): metadata.organizationId → Organization
|
|
309
|
-
// path; absent → the
|
|
314
|
+
// path; absent → the User path (single-tenant).
|
|
310
315
|
const organizationId = session.metadata?.["organizationId"] ?? null;
|
|
311
316
|
let billingTask: Promise<void> = Promise.resolve();
|
|
312
317
|
if (session.customer) {
|
|
@@ -318,7 +323,8 @@ export const stripeWebhookHandler = async (
|
|
|
318
323
|
})
|
|
319
324
|
: upsertBillingIdentity(request, {
|
|
320
325
|
customerId: session.customer,
|
|
321
|
-
|
|
326
|
+
userId: signalOf(session.metadata),
|
|
327
|
+
eventType: event.type,
|
|
322
328
|
eventIdPrefix,
|
|
323
329
|
});
|
|
324
330
|
}
|
|
@@ -704,14 +710,44 @@ async function stampRequestPaidByInvoice(
|
|
|
704
710
|
}
|
|
705
711
|
}
|
|
706
712
|
|
|
713
|
+
/**
|
|
714
|
+
* The verified user signal (#6852): `metadata.ailk_user_id` on the Checkout
|
|
715
|
+
* session or the Stripe customer, when it is a non-empty string. Never
|
|
716
|
+
* `client_reference_id` (a Payment Link accepts it from the payer's URL) and
|
|
717
|
+
* never the payer-typed email.
|
|
718
|
+
*/
|
|
719
|
+
function signalOf(
|
|
720
|
+
metadata: Record<string, string> | null | undefined,
|
|
721
|
+
): string | null {
|
|
722
|
+
const value: unknown = metadata?.["ailk_user_id"];
|
|
723
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
724
|
+
}
|
|
725
|
+
|
|
707
726
|
/**
|
|
708
727
|
* Link User.billingProvider + User.billingCustomerId for a Stripe customer.
|
|
709
728
|
*
|
|
710
729
|
* Lookup priority:
|
|
711
730
|
* 1. Typed composite match: (billingProvider="stripe", billingCustomerId=customerId)
|
|
712
731
|
* → already linked; no-op (idempotent)
|
|
713
|
-
* 2.
|
|
714
|
-
*
|
|
732
|
+
* 2. Verified user signal (#6852): `metadata.ailk_user_id` →
|
|
733
|
+
* User.findUnique({ where: { id } }) → bind only when the row's
|
|
734
|
+
* billingCustomerId is empty; otherwise log billing_identity_conflict and
|
|
735
|
+
* write nothing (#6113). The write is guarded on `billingCustomerId: null`;
|
|
736
|
+
* a P2025 means a concurrent event bound the row first — the same customer
|
|
737
|
+
* is a no-op, any other is a conflict.
|
|
738
|
+
* No signal → log billing_identity_unverified and write nothing. The payer
|
|
739
|
+
* types the Checkout email and Stripe never verifies it, so an email
|
|
740
|
+
* never makes a binding, first or otherwise.
|
|
741
|
+
* Trust boundary: Checkout-session and customer `metadata` can be written
|
|
742
|
+
* only by a principal the seller authorised on their Stripe account (our
|
|
743
|
+
* server's key, a write-capable restricted key, a connected app, the
|
|
744
|
+
* dashboard, a Payment Link's own metadata) — never by the payer. A
|
|
745
|
+
* producer sets the key only from an authenticated server session, never
|
|
746
|
+
* from request input.
|
|
747
|
+
* 3. Signal names no User → warn billing_identity_unknown_user; do NOT
|
|
748
|
+
* create a User row (pre-signup state preserved for Phase B #852)
|
|
749
|
+
*
|
|
750
|
+
* No line logs the email, the Stripe customer id, or an unresolved signal.
|
|
715
751
|
*
|
|
716
752
|
* DB failures are caught and logged — a broken DB link should not cause Stripe to retry
|
|
717
753
|
* the event (which would fire analytics + billing again on eventual success).
|
|
@@ -720,9 +756,15 @@ async function upsertBillingIdentity(
|
|
|
720
756
|
request: FastifyRequest,
|
|
721
757
|
{
|
|
722
758
|
customerId,
|
|
723
|
-
|
|
759
|
+
userId,
|
|
760
|
+
eventType,
|
|
724
761
|
eventIdPrefix,
|
|
725
|
-
}: {
|
|
762
|
+
}: {
|
|
763
|
+
customerId: string;
|
|
764
|
+
userId: string | null;
|
|
765
|
+
eventType: string;
|
|
766
|
+
eventIdPrefix: string;
|
|
767
|
+
},
|
|
726
768
|
): Promise<void> {
|
|
727
769
|
try {
|
|
728
770
|
const existingByBilling = await prisma.user.findFirst({
|
|
@@ -740,30 +782,80 @@ async function upsertBillingIdentity(
|
|
|
740
782
|
return;
|
|
741
783
|
}
|
|
742
784
|
|
|
743
|
-
if (
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
785
|
+
if (!userId) {
|
|
786
|
+
// A customer Checkout creates never carries session metadata, so a
|
|
787
|
+
// no-signal customer.created is every ordinary purchase — info, not warn.
|
|
788
|
+
const fields = {
|
|
789
|
+
eventIdPrefix,
|
|
790
|
+
eventType,
|
|
791
|
+
outcome: "billing_identity_unverified",
|
|
792
|
+
};
|
|
793
|
+
const msg =
|
|
794
|
+
"stripe webhook: no verified user signal — billing identity not bound";
|
|
795
|
+
if (eventType === "customer.created") request.log.info(fields, msg);
|
|
796
|
+
else request.log.warn(fields, msg);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
747
799
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
);
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
800
|
+
const row = await prisma.user.findUnique({
|
|
801
|
+
where: { id: userId },
|
|
802
|
+
select: { id: true, billingProvider: true, billingCustomerId: true },
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
if (!row) {
|
|
806
|
+
request.log.warn(
|
|
807
|
+
{ eventIdPrefix, eventType, outcome: "billing_identity_unknown_user" },
|
|
808
|
+
"stripe webhook: user signal names no User — pre-signup state preserved",
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
762
811
|
}
|
|
763
812
|
|
|
764
|
-
|
|
813
|
+
const isSameBinding = (r: {
|
|
814
|
+
billingProvider: string | null;
|
|
815
|
+
billingCustomerId: string | null;
|
|
816
|
+
}) => r.billingProvider === "stripe" && r.billingCustomerId === customerId;
|
|
817
|
+
const logAlreadyLinked = () =>
|
|
818
|
+
request.log.info(
|
|
819
|
+
{ eventIdPrefix, outcome: "billing_identity_already_linked" },
|
|
820
|
+
"billing identity already linked — idempotent no-op",
|
|
821
|
+
);
|
|
822
|
+
const logConflict = () =>
|
|
823
|
+
request.log.warn(
|
|
824
|
+
{
|
|
825
|
+
eventIdPrefix,
|
|
826
|
+
outcome: "billing_identity_conflict",
|
|
827
|
+
userId: row.id,
|
|
828
|
+
},
|
|
829
|
+
"stripe webhook: user already bound to a different billing customer — not re-pointed",
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
if (row.billingCustomerId !== null) {
|
|
833
|
+
if (isSameBinding(row)) logAlreadyLinked();
|
|
834
|
+
else logConflict();
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
try {
|
|
839
|
+
await prisma.user.update({
|
|
840
|
+
where: { id: row.id, billingCustomerId: null },
|
|
841
|
+
data: {
|
|
842
|
+
billingProvider: "stripe",
|
|
843
|
+
billingCustomerId: customerId,
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
} catch (err) {
|
|
847
|
+
if ((err as { code?: unknown }).code !== "P2025") throw err;
|
|
848
|
+
const now = await prisma.user.findUnique({
|
|
849
|
+
where: { id: row.id },
|
|
850
|
+
select: { billingProvider: true, billingCustomerId: true },
|
|
851
|
+
});
|
|
852
|
+
if (now && isSameBinding(now)) logAlreadyLinked();
|
|
853
|
+
else logConflict();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
request.log.info(
|
|
765
857
|
{ eventIdPrefix },
|
|
766
|
-
"
|
|
858
|
+
"billing identity linked via verified user signal",
|
|
767
859
|
);
|
|
768
860
|
} catch (err) {
|
|
769
861
|
request.log.error(
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import cors from "@fastify/cors";
|
|
13
13
|
import rateLimit from "@fastify/rate-limit";
|
|
14
14
|
import { idRegex } from "@working-theory/ids";
|
|
15
|
+
import { logger } from "@working-theory/observability";
|
|
15
16
|
import Fastify from "fastify";
|
|
16
17
|
import type {
|
|
17
18
|
FastifyPluginAsync,
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
import { searchContentRoute } from "./routes/content/search.js";
|
|
59
60
|
import {
|
|
60
61
|
leadCallbackRoute,
|
|
62
|
+
leadCaptureStatusRoute,
|
|
61
63
|
leadSubmitRoute,
|
|
62
64
|
leadSubscribeRoute,
|
|
63
65
|
} from "./routes/leads/index.js";
|
|
@@ -506,6 +508,31 @@ async function assertCommerceSandboxKey(key: string): Promise<void> {
|
|
|
506
508
|
assertSandboxKeyFn(key);
|
|
507
509
|
}
|
|
508
510
|
|
|
511
|
+
/**
|
|
512
|
+
* #6889 (SEC-02 of the #6876 security review) — make the checkout route's
|
|
513
|
+
* posture observable. POST /v1/checkout/sessions admits a caller without
|
|
514
|
+
* X-Internal-Auth when OSS_API_INTERNAL_SECRET is unset. With FRONTEND_URL
|
|
515
|
+
* unset, the route's same-origin redirect lock (#6877) answers 500
|
|
516
|
+
* config-error to that caller, and the CORS and cookie-CSRF origin checks pin
|
|
517
|
+
* to the `http://localhost:3000` config default. Both stay optional (OSS
|
|
518
|
+
* standalone compat), so this only ever WARNS — once per unset variable, and
|
|
519
|
+
* only in production. FRONTEND_URL is read from the raw environment because
|
|
520
|
+
* `config.FRONTEND_URL` carries that localhost default.
|
|
521
|
+
*/
|
|
522
|
+
function warnOnUnsetProductionProtections(): void {
|
|
523
|
+
if (config.NODE_ENV !== "production") return;
|
|
524
|
+
if (!process.env["FRONTEND_URL"]) {
|
|
525
|
+
logger.warn(
|
|
526
|
+
"[startup] FRONTEND_URL not set in production — CORS/cookie origin checks are pinned to http://localhost:3000, and checkout answers 500 config-error to any caller admitted without X-Internal-Auth",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
if (!process.env["OSS_API_INTERNAL_SECRET"]) {
|
|
530
|
+
logger.warn(
|
|
531
|
+
"[startup] OSS_API_INTERNAL_SECRET not set in production — the checkout route admits callers without X-Internal-Auth",
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
509
536
|
/**
|
|
510
537
|
* Build Fastify server instance (for serverless or standalone)
|
|
511
538
|
* @returns Configured Fastify instance ready for requests
|
|
@@ -514,6 +541,8 @@ export async function build() {
|
|
|
514
541
|
// Startup-time validation: fail fast if LEAD_CAPTURE_FILE points at a missing directory.
|
|
515
542
|
await validateLeadCaptureDir();
|
|
516
543
|
|
|
544
|
+
warnOnUnsetProductionProtections();
|
|
545
|
+
|
|
517
546
|
// #6155 D6 — the site-alias workspace assertion (ownership brief § Layer
|
|
518
547
|
// 2, AC-5): resolves + asserts once at startup that AILK_SITE_ID (or its
|
|
519
548
|
// "default" fallback) names a real workspace, throwing when it does not —
|
|
@@ -1145,6 +1174,12 @@ export async function build() {
|
|
|
1145
1174
|
await authEmailScope.register(transactionalEmailRoute);
|
|
1146
1175
|
});
|
|
1147
1176
|
|
|
1177
|
+
// Lead-capture status (#6730 D4) — a cheap read the web page makes before
|
|
1178
|
+
// choosing the form or the contact email. Top level, NOT in `leadsScope`:
|
|
1179
|
+
// the web server's single egress IP already spends that scope's strict
|
|
1180
|
+
// 10/min on form submissions. The global unauth ceiling above applies.
|
|
1181
|
+
await server.register(leadCaptureStatusRoute);
|
|
1182
|
+
|
|
1148
1183
|
// Leads routes — rate-limit middleware scoped to this sub-router. Own
|
|
1149
1184
|
// default (10/min, pre-#4730) rather than inheriting the wider
|
|
1150
1185
|
// flows-sized default (#5111, DEF-004 — #4730's shared-default bump was
|
|
@@ -553,3 +553,197 @@ describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () =>
|
|
|
553
553
|
});
|
|
554
554
|
});
|
|
555
555
|
});
|
|
556
|
+
|
|
557
|
+
// ─── #6730 D1/D2 — success only for a durable row or an accepted email ───────
|
|
558
|
+
|
|
559
|
+
describe("#6730 — routeLead delivery modes", () => {
|
|
560
|
+
const ENV_KEYS = [
|
|
561
|
+
"DATABASE_URL",
|
|
562
|
+
"NODE_ENV",
|
|
563
|
+
"RESEND_API_KEY",
|
|
564
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
565
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
566
|
+
"AILK_INBOX_FALLBACK_DIR",
|
|
567
|
+
"LEAD_CAPTURE_FILE",
|
|
568
|
+
] as const;
|
|
569
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
570
|
+
let tmpDir: string;
|
|
571
|
+
const listFiles = () => fs.readdir(tmpDir);
|
|
572
|
+
|
|
573
|
+
const EMAIL_ENV = {
|
|
574
|
+
RESEND_API_KEY: "re_test",
|
|
575
|
+
AILK_LEAD_NOTIFICATION_TO: "owner@example.com",
|
|
576
|
+
AILK_LEAD_NOTIFICATION_FROM: "noreply@example.com",
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
beforeEach(async () => {
|
|
580
|
+
jest.clearAllMocks();
|
|
581
|
+
saved = {};
|
|
582
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
583
|
+
for (const k of ENV_KEYS) delete process.env[k];
|
|
584
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "ailk-delivery-test-"));
|
|
585
|
+
process.env.AILK_INBOX_FALLBACK_DIR = tmpDir;
|
|
586
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({ id: "lead_db_1" });
|
|
587
|
+
mockSendLeadNotification.mockResolvedValue({ ok: true, messageId: "msg-1" });
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
afterEach(async () => {
|
|
591
|
+
for (const k of ENV_KEYS) {
|
|
592
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
593
|
+
else process.env[k] = saved[k];
|
|
594
|
+
}
|
|
595
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
describe("email delivery (the three email vars, no DATABASE_URL, production)", () => {
|
|
599
|
+
beforeEach(() => {
|
|
600
|
+
Object.assign(process.env, EMAIL_ENV, { NODE_ENV: "production" });
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("an accepted email → ok:true persisted:email with an email- id; no Prisma, no file fallback", async () => {
|
|
604
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
605
|
+
RouteLeadResult,
|
|
606
|
+
{ ok: true }
|
|
607
|
+
>;
|
|
608
|
+
|
|
609
|
+
expect(result).toMatchObject({
|
|
610
|
+
ok: true,
|
|
611
|
+
persisted: "email",
|
|
612
|
+
email: { ok: true, messageId: "msg-1" },
|
|
613
|
+
});
|
|
614
|
+
expect(result.id).toMatch(/^email-/);
|
|
615
|
+
expect(result.id).toMatch(/^[a-z0-9_-]{8,64}$/i);
|
|
616
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
617
|
+
expect(await listFiles()).toEqual([]);
|
|
618
|
+
expect(mockSendLeadNotification).toHaveBeenCalledTimes(1);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("a refused email → ok:false delivery_failed (never reported as sent)", async () => {
|
|
622
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
623
|
+
ok: false,
|
|
624
|
+
error: { kind: "provider_error", message: "rejected" },
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
628
|
+
RouteLeadResult,
|
|
629
|
+
{ ok: false }
|
|
630
|
+
>;
|
|
631
|
+
|
|
632
|
+
expect(result.ok).toBe(false);
|
|
633
|
+
expect(result.error.kind).toBe("delivery_failed");
|
|
634
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
635
|
+
expect(await listFiles()).toEqual([]);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it("a skipped email (provider reports the key missing) → delivery_failed", async () => {
|
|
639
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
640
|
+
ok: false,
|
|
641
|
+
error: { kind: "config_missing", message: "RESEND_API_KEY not set" },
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
const result = await routeLead(leadInput, sourceMeta);
|
|
645
|
+
expect(result).toMatchObject({ ok: false, error: { kind: "delivery_failed" } });
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("a callback is delivered by email too — carrying the name and number to call", async () => {
|
|
649
|
+
const result = await routeLead(
|
|
650
|
+
{ ...callbackInput, preferredTime: "mornings" },
|
|
651
|
+
sourceMeta,
|
|
652
|
+
);
|
|
653
|
+
expect(result).toMatchObject({ ok: true, persisted: "email" });
|
|
654
|
+
expect(prisma.callback.create).not.toHaveBeenCalled();
|
|
655
|
+
const arg = mockSendLeadNotification.mock.calls[0][0];
|
|
656
|
+
expect(arg.lead.payload).toMatchObject({
|
|
657
|
+
name: "Alice",
|
|
658
|
+
phone: "+15551234567",
|
|
659
|
+
preferredTime: "mornings",
|
|
660
|
+
notes: "urgent",
|
|
661
|
+
});
|
|
662
|
+
// No visitor address to reply to — never an empty Reply-To.
|
|
663
|
+
expect(arg.replyToOverride).toBe("owner@example.com");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("a newsletter subscribe is refused with config_missing — a signup needs a database", async () => {
|
|
667
|
+
const result = await routeLead(subscribeInput, sourceMeta);
|
|
668
|
+
expect(result).toMatchObject({ ok: false, error: { kind: "config_missing" } });
|
|
669
|
+
expect(prisma.subscriber.create).not.toHaveBeenCalled();
|
|
670
|
+
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
describe("production with neither a database nor the email vars", () => {
|
|
675
|
+
beforeEach(() => {
|
|
676
|
+
process.env.NODE_ENV = "production";
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("refuses with config_missing before any write", async () => {
|
|
680
|
+
const result = await routeLead(leadInput, sourceMeta);
|
|
681
|
+
|
|
682
|
+
expect(result).toMatchObject({ ok: false, error: { kind: "config_missing" } });
|
|
683
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
684
|
+
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
685
|
+
expect(await listFiles()).toEqual([]);
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
describe("inbox delivery in production with the database failing (not a constraint)", () => {
|
|
690
|
+
beforeEach(() => {
|
|
691
|
+
process.env.DATABASE_URL = "postgres://test";
|
|
692
|
+
process.env.NODE_ENV = "production";
|
|
693
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("no email vars → persist_failed, and the non-durable file fallback is not attempted", async () => {
|
|
697
|
+
const result = await routeLead(leadInput, sourceMeta);
|
|
698
|
+
|
|
699
|
+
expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
|
|
700
|
+
expect(await listFiles()).toEqual([]);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("email vars set and the send accepted → persisted:email", async () => {
|
|
704
|
+
Object.assign(process.env, EMAIL_ENV);
|
|
705
|
+
|
|
706
|
+
const result = (await routeLead(leadInput, sourceMeta)) as Extract<
|
|
707
|
+
RouteLeadResult,
|
|
708
|
+
{ ok: true }
|
|
709
|
+
>;
|
|
710
|
+
|
|
711
|
+
expect(result).toMatchObject({ ok: true, persisted: "email" });
|
|
712
|
+
expect(result.id).toMatch(/^email-/);
|
|
713
|
+
expect(await listFiles()).toEqual([]);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("a newsletter signup is never emailed — persist_failed", async () => {
|
|
717
|
+
Object.assign(process.env, EMAIL_ENV);
|
|
718
|
+
(prisma.subscriber.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
|
|
719
|
+
|
|
720
|
+
const result = await routeLead(subscribeInput, sourceMeta);
|
|
721
|
+
|
|
722
|
+
expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
|
|
723
|
+
expect(mockSendLeadNotification).not.toHaveBeenCalled();
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("email vars set but the send refused → persist_failed", async () => {
|
|
727
|
+
Object.assign(process.env, EMAIL_ENV);
|
|
728
|
+
mockSendLeadNotification.mockResolvedValue({
|
|
729
|
+
ok: false,
|
|
730
|
+
error: { kind: "provider_error", message: "rejected" },
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
const result = await routeLead(leadInput, sourceMeta);
|
|
734
|
+
expect(result).toMatchObject({ ok: false, error: { kind: "persist_failed" } });
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
describe("inbox delivery outside production keeps today's file fallback", () => {
|
|
739
|
+
it("DB down in test → persisted:file", async () => {
|
|
740
|
+
process.env.DATABASE_URL = "postgres://test";
|
|
741
|
+
process.env.NODE_ENV = "test";
|
|
742
|
+
(prisma.lead.create as jest.Mock).mockRejectedValue(new Error("connection refused"));
|
|
743
|
+
|
|
744
|
+
const result = await routeLead(leadInput, sourceMeta);
|
|
745
|
+
expect(result).toMatchObject({ ok: true, persisted: "file" });
|
|
746
|
+
expect((await listFiles()).length).toBeGreaterThan(0);
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
});
|
|
@@ -327,3 +327,127 @@ describe("listOwnedLeads", () => {
|
|
|
327
327
|
expect(second.value.nextCursor).toBeNull();
|
|
328
328
|
});
|
|
329
329
|
});
|
|
330
|
+
|
|
331
|
+
// ─── #6730 D7 — the single-tenant inbox, pinned to the operator ──────────────
|
|
332
|
+
|
|
333
|
+
describe("listOwnedLeads — single-tenant build (#6730 D7)", () => {
|
|
334
|
+
const { DEFAULT_ORG_ID, DEFAULT_WORKSPACE_ID } = jest.requireActual(
|
|
335
|
+
"@working-theory/database/tenancy",
|
|
336
|
+
) as { DEFAULT_ORG_ID: string; DEFAULT_WORKSPACE_ID: string };
|
|
337
|
+
const OPERATOR_EMAIL = "owner@example.com";
|
|
338
|
+
const prior = {
|
|
339
|
+
modules: process.env.AILK_ENABLED_MODULES,
|
|
340
|
+
pin: process.env.AILK_OPERATOR_EMAIL,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/** One workspace — the seeded default — and a caller whose verified email is `email`. */
|
|
344
|
+
function oneWorkspaceDeployment(opts: {
|
|
345
|
+
email: string;
|
|
346
|
+
verified?: boolean;
|
|
347
|
+
workspaceCount?: number;
|
|
348
|
+
role?: string;
|
|
349
|
+
}) {
|
|
350
|
+
(prisma.workspace.count as jest.Mock).mockResolvedValue(opts.workspaceCount ?? 1);
|
|
351
|
+
(prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
|
|
352
|
+
id: DEFAULT_WORKSPACE_ID,
|
|
353
|
+
organizationId: DEFAULT_ORG_ID,
|
|
354
|
+
});
|
|
355
|
+
(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
|
356
|
+
id: USER_ID,
|
|
357
|
+
email: opts.email,
|
|
358
|
+
emailVerified: opts.verified ?? true,
|
|
359
|
+
});
|
|
360
|
+
(prisma.membership.create as jest.Mock).mockResolvedValue({});
|
|
361
|
+
(prisma.membership.findUnique as jest.Mock).mockResolvedValue({
|
|
362
|
+
role: opts.role ?? "owner",
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
beforeEach(() => {
|
|
367
|
+
jest.clearAllMocks();
|
|
368
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
369
|
+
process.env.AILK_OPERATOR_EMAIL = OPERATOR_EMAIL;
|
|
370
|
+
seedLeadTable([
|
|
371
|
+
lead({ id: "lead_own", siteId: DEFAULT_WORKSPACE_ID }),
|
|
372
|
+
lead({ id: "lead_elsewhere", siteId: OTHER_SITE_ID }),
|
|
373
|
+
]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
afterAll(() => {
|
|
377
|
+
for (const [key, value] of [
|
|
378
|
+
["AILK_ENABLED_MODULES", prior.modules],
|
|
379
|
+
["AILK_OPERATOR_EMAIL", prior.pin],
|
|
380
|
+
] as const) {
|
|
381
|
+
if (value === undefined) delete process.env[key];
|
|
382
|
+
else process.env[key] = value;
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("the pinned, verified owner sees the default workspace's leads — and only those", async () => {
|
|
387
|
+
oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
|
|
388
|
+
|
|
389
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
390
|
+
|
|
391
|
+
expect(result.ok).toBe(true);
|
|
392
|
+
if (!result.ok) return;
|
|
393
|
+
expect(result.value.items.map((r) => r.id)).toEqual(["lead_own"]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("the pin unset → empty", async () => {
|
|
397
|
+
delete process.env.AILK_OPERATOR_EMAIL;
|
|
398
|
+
oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
|
|
399
|
+
|
|
400
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
401
|
+
|
|
402
|
+
expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
|
|
403
|
+
expect(prisma.lead.findMany).not.toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("a different verified email → empty", async () => {
|
|
407
|
+
oneWorkspaceDeployment({ email: "stranger@example.com" });
|
|
408
|
+
|
|
409
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
410
|
+
|
|
411
|
+
expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
|
|
412
|
+
expect(prisma.lead.findMany).not.toHaveBeenCalled();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("the pinned email, unverified → empty", async () => {
|
|
416
|
+
oneWorkspaceDeployment({ email: OPERATOR_EMAIL, verified: false });
|
|
417
|
+
|
|
418
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
419
|
+
|
|
420
|
+
expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("a second workspace → empty (not unambiguously one-workspace)", async () => {
|
|
424
|
+
oneWorkspaceDeployment({ email: OPERATOR_EMAIL, workspaceCount: 2 });
|
|
425
|
+
|
|
426
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
427
|
+
|
|
428
|
+
expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("a `member` role → empty — the role floor still holds", async () => {
|
|
432
|
+
oneWorkspaceDeployment({ email: OPERATOR_EMAIL, role: "member" });
|
|
433
|
+
|
|
434
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
435
|
+
|
|
436
|
+
expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("with multi-tenant on, resolveOwnerWorkspaces is the path — the default-tenant resolver is not called", async () => {
|
|
440
|
+
process.env.AILK_ENABLED_MODULES = "multi-tenant";
|
|
441
|
+
oneWorkspaceDeployment({ email: OPERATOR_EMAIL });
|
|
442
|
+
(prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
|
|
443
|
+
const ownerWorkspaces = jest.requireActual("../owner-workspaces.js");
|
|
444
|
+
const spy = jest.spyOn(ownerWorkspaces, "resolveOwnerWorkspaces");
|
|
445
|
+
|
|
446
|
+
const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
|
|
447
|
+
|
|
448
|
+
expect(spy).toHaveBeenCalledWith(USER_ID);
|
|
449
|
+
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
450
|
+
expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
|
|
451
|
+
spy.mockRestore();
|
|
452
|
+
});
|
|
453
|
+
});
|