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
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* loopback HTTP server (spec D11), not a `global.fetch` double: the contract
|
|
5
5
|
* under test is the wire format, and only a real server proves that the bytes
|
|
6
6
|
* on the wire are the bytes the signature covers. The server recomputes the
|
|
7
|
-
* HMAC inside its own handler
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* HMAC inside its own handler over the D1 base string
|
|
8
|
+
* `v0:{timestamp}:{event}:{rawBody}` (#5902) — the receiver's verification,
|
|
9
|
+
* performed for real — and compares with `timingSafeEqual`, exactly as the
|
|
10
|
+
* receiver does (spec FR7).
|
|
10
11
|
*
|
|
11
12
|
* The server binds 127.0.0.1, which `ssrfBlocked` rejects; D7's
|
|
12
13
|
* `allowPrivate: true` is what makes this substrate runnable at all.
|
|
@@ -23,10 +24,13 @@ import type { AddressInfo } from "node:net";
|
|
|
23
24
|
const TEST_SECRET = "not-a-real-secret-fixture-value";
|
|
24
25
|
const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
|
|
25
26
|
const EVENT_HEADER = "x-ailk-revalidate-event";
|
|
27
|
+
const TIMESTAMP_HEADER = "x-ailk-revalidate-timestamp";
|
|
26
28
|
|
|
29
|
+
// `type` must be a registered page type and `slug` a SLUG_PATTERN value — the
|
|
30
|
+
// sender validates both before signing (#5902).
|
|
27
31
|
const TARGET = {
|
|
28
32
|
workspaceId: "ws_test_0001",
|
|
29
|
-
type: "
|
|
33
|
+
type: "Service",
|
|
30
34
|
slug: "pricing",
|
|
31
35
|
locale: "en",
|
|
32
36
|
};
|
|
@@ -63,8 +67,11 @@ jest.mock("@working-theory/observability", () => ({
|
|
|
63
67
|
interface Recorded {
|
|
64
68
|
rawBody: string;
|
|
65
69
|
signatureValid: boolean;
|
|
70
|
+
/** Whether the signature is HMAC over the raw body ALONE (the pre-#5902 wire). */
|
|
71
|
+
bodyOnlySignatureValid: boolean;
|
|
66
72
|
signatureHeader: string;
|
|
67
73
|
eventHeader: string;
|
|
74
|
+
timestampHeader: string;
|
|
68
75
|
contentType: string;
|
|
69
76
|
receivedAt: number;
|
|
70
77
|
}
|
|
@@ -81,26 +88,40 @@ beforeAll(async () => {
|
|
|
81
88
|
req.on("data", (c: Buffer) => void chunks.push(c));
|
|
82
89
|
req.on("end", () => {
|
|
83
90
|
const raw = Buffer.concat(chunks);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// RAW request body bytes and compare in constant time.
|
|
87
|
-
const expected = Buffer.from(
|
|
88
|
-
createHmac("sha256", TEST_SECRET).update(raw).digest("hex"),
|
|
89
|
-
"utf8",
|
|
90
|
-
);
|
|
91
|
+
const timestampHeader = String(req.headers[TIMESTAMP_HEADER] ?? "");
|
|
92
|
+
const eventHeader = String(req.headers[EVENT_HEADER] ?? "");
|
|
91
93
|
const received = Buffer.from(
|
|
92
94
|
String(req.headers[SIGNATURE_HEADER] ?? "").replace(/^sha256=/, ""),
|
|
93
95
|
"utf8",
|
|
94
96
|
);
|
|
95
|
-
const
|
|
96
|
-
expected
|
|
97
|
-
|
|
97
|
+
const matches = (material: Buffer): boolean => {
|
|
98
|
+
const expected = Buffer.from(
|
|
99
|
+
createHmac("sha256", TEST_SECRET).update(material).digest("hex"),
|
|
100
|
+
"utf8",
|
|
101
|
+
);
|
|
102
|
+
return (
|
|
103
|
+
expected.length === received.length &&
|
|
104
|
+
timingSafeEqual(expected, received)
|
|
105
|
+
);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// The receiver's own verification (FR7, D1 as amended by #5902):
|
|
109
|
+
// recompute HMAC-SHA256 over `v0:{timestamp}:{event}:` followed by the
|
|
110
|
+
// RAW request body bytes, and compare in constant time.
|
|
111
|
+
const signatureValid = matches(
|
|
112
|
+
Buffer.concat([
|
|
113
|
+
Buffer.from(`v0:${timestampHeader}:${eventHeader}:`, "utf8"),
|
|
114
|
+
raw,
|
|
115
|
+
]),
|
|
116
|
+
);
|
|
98
117
|
|
|
99
118
|
recorded.push({
|
|
100
119
|
rawBody: raw.toString("utf8"),
|
|
101
120
|
signatureValid,
|
|
121
|
+
bodyOnlySignatureValid: matches(raw),
|
|
102
122
|
signatureHeader: String(req.headers[SIGNATURE_HEADER] ?? ""),
|
|
103
|
-
eventHeader
|
|
123
|
+
eventHeader,
|
|
124
|
+
timestampHeader,
|
|
104
125
|
contentType: String(req.headers["content-type"] ?? ""),
|
|
105
126
|
receivedAt: Date.now(),
|
|
106
127
|
});
|
|
@@ -219,6 +240,60 @@ describe("notifyRevalidate", () => {
|
|
|
219
240
|
});
|
|
220
241
|
}, 20_000);
|
|
221
242
|
|
|
243
|
+
it("signs v0:{timestamp}:{event}:{body} and sends the timestamp header (#5902)", async () => {
|
|
244
|
+
responseStatuses = [200];
|
|
245
|
+
const before = Math.floor(Date.now() / 1000);
|
|
246
|
+
|
|
247
|
+
await loadService().notifyRevalidate(TARGET, { event: "unpublish" });
|
|
248
|
+
|
|
249
|
+
const after = Math.floor(Date.now() / 1000);
|
|
250
|
+
expect(recorded).toHaveLength(1);
|
|
251
|
+
const [req] = recorded;
|
|
252
|
+
|
|
253
|
+
// Unix seconds, digits only, taken at send time — what the receiver checks
|
|
254
|
+
// its 5-minute tolerance against.
|
|
255
|
+
expect(req.timestampHeader).toMatch(/^[0-9]+$/);
|
|
256
|
+
const ts = Number(req.timestampHeader);
|
|
257
|
+
expect(ts).toBeGreaterThanOrEqual(before);
|
|
258
|
+
expect(ts).toBeLessThanOrEqual(after);
|
|
259
|
+
|
|
260
|
+
// The base-string shape, recomputed independently here: the timestamp and
|
|
261
|
+
// the event are inside the MAC, so neither can be altered in flight.
|
|
262
|
+
const expected = createHmac("sha256", TEST_SECRET)
|
|
263
|
+
.update(`v0:${req.timestampHeader}:unpublish:${req.rawBody}`, "utf8")
|
|
264
|
+
.digest("hex");
|
|
265
|
+
expect(req.signatureHeader).toBe(`sha256=${expected}`);
|
|
266
|
+
expect(req.signatureValid).toBe(true);
|
|
267
|
+
// And the body-alone signature of the pre-#5902 wire no longer verifies.
|
|
268
|
+
expect(req.bodyOnlySignatureValid).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it.each([
|
|
272
|
+
["type", "page"], // not a registered page type
|
|
273
|
+
["type", "Service:some-slug:en"], // the tag-collision shape (#6005 M3)
|
|
274
|
+
["type", "S".repeat(300)],
|
|
275
|
+
["slug", "Bad Slug"],
|
|
276
|
+
["slug", "a:b"],
|
|
277
|
+
["slug", ""],
|
|
278
|
+
["locale", "english"],
|
|
279
|
+
["locale", "en:x"],
|
|
280
|
+
["locale", ""],
|
|
281
|
+
])(
|
|
282
|
+
"throws before any outbound call on an invalid %s (%j) (#5902)",
|
|
283
|
+
async (field, value) => {
|
|
284
|
+
responseStatuses = [200];
|
|
285
|
+
|
|
286
|
+
await expect(
|
|
287
|
+
loadService().notifyRevalidate(
|
|
288
|
+
{ ...TARGET, [field]: value },
|
|
289
|
+
{ event: "publish" },
|
|
290
|
+
),
|
|
291
|
+
).rejects.toThrow();
|
|
292
|
+
|
|
293
|
+
expect(recorded).toHaveLength(0);
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
222
297
|
it("is a single-warning no-op with CONTENT_REVALIDATE_URL unset", async () => {
|
|
223
298
|
delete process.env.CONTENT_REVALIDATE_URL;
|
|
224
299
|
delete process.env.CONTENT_REVALIDATE_SECRET;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file checkout-buyer.ts
|
|
3
|
+
* @description Resolves a signed-in user into the Checkout buyer
|
|
4
|
+
* `POST /v1/checkout/sessions` pins and stamps (#6866 D3).
|
|
5
|
+
*
|
|
6
|
+
* A buyer is a user whose email is VERIFIED — the customer created for them
|
|
7
|
+
* carries the account email, and it must never carry an unproven one — and
|
|
8
|
+
* whose billing binding, if any, is a Stripe customer. The binding decides
|
|
9
|
+
* whether the library reuses a customer (`customerId`) or creates one
|
|
10
|
+
* (`null`). A row bound under any other provider is not a buyer: the webhook
|
|
11
|
+
* never re-points a non-null `billingCustomerId` (#6113), so a new stamped
|
|
12
|
+
* customer could only be an orphan.
|
|
13
|
+
*
|
|
14
|
+
* Base tree (N4): imports only `verified-email.ts` and `lib/prisma.ts`, never
|
|
15
|
+
* the commerce overlay's `services/subscription.ts`. The billing read selects
|
|
16
|
+
* no email, so `tools/lint-email-authz.ts` has nothing to flag.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { prisma } from "../lib/prisma.js";
|
|
20
|
+
|
|
21
|
+
import { findVerifiedEmail } from "./verified-email.js";
|
|
22
|
+
|
|
23
|
+
export type CheckoutBuyer = {
|
|
24
|
+
userId: string;
|
|
25
|
+
email: string;
|
|
26
|
+
/** The user's bound Stripe customer, or `null` when one is to be created. */
|
|
27
|
+
customerId: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CheckoutBuyerResult =
|
|
31
|
+
| { ok: true; buyer: CheckoutBuyer }
|
|
32
|
+
| {
|
|
33
|
+
ok: false;
|
|
34
|
+
reason: "user_not_found" | "email_unverified" | "foreign_provider";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param userId the id resolved from the authenticated session cookie — never
|
|
39
|
+
* a request-supplied value.
|
|
40
|
+
* @throws whatever Prisma throws; the caller degrades that to anonymous (F7).
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveCheckoutBuyer(
|
|
43
|
+
userId: string,
|
|
44
|
+
): Promise<CheckoutBuyerResult> {
|
|
45
|
+
const verified = await findVerifiedEmail(userId);
|
|
46
|
+
if (!verified.ok) return { ok: false, reason: verified.reason };
|
|
47
|
+
|
|
48
|
+
const billing = await prisma.user.findUnique({
|
|
49
|
+
where: { id: userId },
|
|
50
|
+
select: { billingProvider: true, billingCustomerId: true },
|
|
51
|
+
});
|
|
52
|
+
if (!billing) return { ok: false, reason: "user_not_found" };
|
|
53
|
+
|
|
54
|
+
if (billing.billingCustomerId === null) {
|
|
55
|
+
return {
|
|
56
|
+
ok: true,
|
|
57
|
+
buyer: { userId, email: verified.value.email, customerId: null },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// The same provider predicate `resolveBillingCustomer` applies.
|
|
61
|
+
if (billing.billingProvider !== "stripe") {
|
|
62
|
+
return { ok: false, reason: "foreign_provider" };
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
ok: true,
|
|
66
|
+
buyer: {
|
|
67
|
+
userId,
|
|
68
|
+
email: verified.value.email,
|
|
69
|
+
customerId: billing.billingCustomerId,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
* @file lead-routing.ts
|
|
3
3
|
* @description Cross-namespace lead orchestration service (ADR 0009 D3).
|
|
4
4
|
*
|
|
5
|
-
* Pipeline: validate → enrich → persist (DB with file
|
|
5
|
+
* Pipeline: validate → delivery guard → enrich → persist (DB with file
|
|
6
|
+
* fallback) → email. Where a lead may go is `resolveLeadDelivery()`
|
|
7
|
+
* (`lib/lead-delivery.ts`, #6730 D1): with no database but the email vars set,
|
|
8
|
+
* the owner email IS the delivery; in production with neither, nothing is
|
|
9
|
+
* accepted. A lead is reported sent only for a durable row or an email the
|
|
10
|
+
* provider accepted — never for the non-durable file fallback in production.
|
|
6
11
|
* This is the first implementation of ADR 0009 D3's service-layer pattern.
|
|
7
12
|
* Future cross-namespace orchestrators in apps/api/src/services/ should mirror
|
|
8
13
|
* this shape: type-result returns, mocked-dep tests, non-transactional secondary
|
|
@@ -32,6 +37,7 @@ import { leadInputSchema } from "@working-theory/validation";
|
|
|
32
37
|
|
|
33
38
|
import { writeLeadFallback } from "../lib/file-fallback.js";
|
|
34
39
|
import { appendLeadRecord } from "../lib/lead-capture-store.js";
|
|
40
|
+
import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
|
|
35
41
|
import { prisma } from "../lib/prisma.js";
|
|
36
42
|
|
|
37
43
|
import { enrichLead } from "./enrichment-shim.js";
|
|
@@ -65,11 +71,14 @@ export type SourceMeta = {
|
|
|
65
71
|
receivedAt: string; // ISO
|
|
66
72
|
};
|
|
67
73
|
|
|
74
|
+
/** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
|
|
75
|
+
export type Persisted = "db" | "file" | "email";
|
|
76
|
+
|
|
68
77
|
export type RouteLeadResult =
|
|
69
78
|
| {
|
|
70
79
|
ok: true;
|
|
71
80
|
id: string;
|
|
72
|
-
persisted:
|
|
81
|
+
persisted: Persisted;
|
|
73
82
|
email:
|
|
74
83
|
| { ok: true; messageId: string }
|
|
75
84
|
| { ok: false; error: string }
|
|
@@ -78,7 +87,11 @@ export type RouteLeadResult =
|
|
|
78
87
|
| {
|
|
79
88
|
ok: false;
|
|
80
89
|
error: {
|
|
81
|
-
kind:
|
|
90
|
+
kind:
|
|
91
|
+
| "invalid_input"
|
|
92
|
+
| "persist_failed"
|
|
93
|
+
| "config_missing"
|
|
94
|
+
| "delivery_failed";
|
|
82
95
|
message: string;
|
|
83
96
|
};
|
|
84
97
|
};
|
|
@@ -272,7 +285,7 @@ type EmailResult =
|
|
|
272
285
|
async function sendEmail(
|
|
273
286
|
input: RouteLeadInput,
|
|
274
287
|
id: string,
|
|
275
|
-
persisted:
|
|
288
|
+
persisted: Persisted,
|
|
276
289
|
): Promise<EmailResult> {
|
|
277
290
|
// Request-and-confirm SUPERSEDES this notification for a callback (#5970
|
|
278
291
|
// D6). AC-1 requires the tenant to receive exactly one email, and the
|
|
@@ -316,7 +329,22 @@ async function sendEmail(
|
|
|
316
329
|
siteId: input.siteId,
|
|
317
330
|
source: input.kind === "callback" ? "callback" : input.source,
|
|
318
331
|
email: input.kind === "callback" ? "" : input.email,
|
|
319
|
-
|
|
332
|
+
// A callback's contact details are top-level fields, not payload keys
|
|
333
|
+
// (#6730): with no database the email is the only record of the
|
|
334
|
+
// request, so it must carry the name and number to call back.
|
|
335
|
+
payload:
|
|
336
|
+
input.kind === "subscribe"
|
|
337
|
+
? {}
|
|
338
|
+
: input.kind === "callback"
|
|
339
|
+
? {
|
|
340
|
+
...input.payload,
|
|
341
|
+
name: input.name,
|
|
342
|
+
phone: input.phone,
|
|
343
|
+
...(input.preferredTime !== undefined && {
|
|
344
|
+
preferredTime: input.preferredTime,
|
|
345
|
+
}),
|
|
346
|
+
}
|
|
347
|
+
: input.payload,
|
|
320
348
|
createdAt: new Date().toISOString(),
|
|
321
349
|
};
|
|
322
350
|
|
|
@@ -324,6 +352,9 @@ async function sendEmail(
|
|
|
324
352
|
lead: leadForEmail,
|
|
325
353
|
recipientEmail: to,
|
|
326
354
|
fromAddress: from,
|
|
355
|
+
// A callback has no visitor address to reply to; an empty Reply-To is
|
|
356
|
+
// not a valid header, so reply to the owner instead.
|
|
357
|
+
...(input.kind === "callback" && { replyToOverride: to }),
|
|
327
358
|
});
|
|
328
359
|
|
|
329
360
|
if (emailResult.ok) {
|
|
@@ -337,6 +368,47 @@ async function sendEmail(
|
|
|
337
368
|
return { ok: false, error: `${kind}: ${message}` };
|
|
338
369
|
}
|
|
339
370
|
|
|
371
|
+
// ─── Email delivery (#6730 D2) ────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
type EmailDelivery =
|
|
374
|
+
| { ok: true; id: string; email: { ok: true; messageId: string } }
|
|
375
|
+
| { ok: false; message: string };
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Deliver a lead as the owner email alone — success only when the provider
|
|
379
|
+
* accepted it. The id matches the file-fallback id format
|
|
380
|
+
* (`/^[a-z0-9_-]{8,64}$/i`).
|
|
381
|
+
*/
|
|
382
|
+
async function deliverByEmail(input: RouteLeadInput): Promise<EmailDelivery> {
|
|
383
|
+
const id = `email-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
384
|
+
const result = await sendEmail(input, id, "email");
|
|
385
|
+
if ("ok" in result && result.ok) return { ok: true, id, email: result };
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
message: "error" in result ? result.error : `email skipped: ${result.reason}`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ─── Lead-capture record ─────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
function captureRecordFor(input: RouteLeadInput, id: string, sourceMeta: SourceMeta) {
|
|
395
|
+
return {
|
|
396
|
+
id,
|
|
397
|
+
createdAt: sourceMeta.receivedAt,
|
|
398
|
+
source: input.kind === "callback" ? "callback" : input.source,
|
|
399
|
+
data:
|
|
400
|
+
input.kind === "callback"
|
|
401
|
+
? { name: input.name, phone: input.phone, ...(input.payload ?? {}) }
|
|
402
|
+
: input.kind === "subscribe"
|
|
403
|
+
? { email: input.email }
|
|
404
|
+
: { email: input.email, ...(input.payload ?? {}) },
|
|
405
|
+
metadata: {
|
|
406
|
+
ip: sourceMeta.ip,
|
|
407
|
+
userAgent: sourceMeta.userAgent,
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
340
412
|
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
341
413
|
|
|
342
414
|
export async function routeLead(
|
|
@@ -352,13 +424,13 @@ export async function routeLead(
|
|
|
352
424
|
};
|
|
353
425
|
}
|
|
354
426
|
|
|
355
|
-
// 2. Guard:
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
) {
|
|
427
|
+
// 2. Guard: where can this lead go (#6730 D1)? A kind the configured
|
|
428
|
+
// delivery cannot take — anything in production with neither a database
|
|
429
|
+
// nor the email vars, a newsletter signup with no database — is refused
|
|
430
|
+
// before any write, so no visitor is told a message was sent when it
|
|
431
|
+
// reached nobody.
|
|
432
|
+
const delivery = resolveLeadDelivery();
|
|
433
|
+
if (!acceptsKind(delivery, input.kind)) {
|
|
362
434
|
return {
|
|
363
435
|
ok: false,
|
|
364
436
|
error: {
|
|
@@ -374,9 +446,28 @@ export async function routeLead(
|
|
|
374
446
|
// the real enrichment call with proper type alignment.
|
|
375
447
|
await enrichLead(input as unknown as Parameters<typeof enrichLead>[0]);
|
|
376
448
|
|
|
449
|
+
// Email delivery — no database: the owner email is the delivery, so skip
|
|
450
|
+
// the DB and the (non-durable) file fallback entirely.
|
|
451
|
+
if (delivery === "email") {
|
|
452
|
+
const emailed = await deliverByEmail(input);
|
|
453
|
+
if (!emailed.ok) {
|
|
454
|
+
return {
|
|
455
|
+
ok: false,
|
|
456
|
+
error: { kind: "delivery_failed", message: emailed.message },
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
await appendLeadRecord(captureRecordFor(input, emailed.id, sourceMeta));
|
|
460
|
+
return {
|
|
461
|
+
ok: true,
|
|
462
|
+
id: emailed.id,
|
|
463
|
+
persisted: "email",
|
|
464
|
+
email: emailed.email,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
377
468
|
// 4. Persist primary (DB), fallback to file on error
|
|
378
469
|
let id: string;
|
|
379
|
-
let persisted:
|
|
470
|
+
let persisted: Persisted;
|
|
380
471
|
let skipEmail = false;
|
|
381
472
|
let skipReason: string | undefined;
|
|
382
473
|
|
|
@@ -424,7 +515,41 @@ export async function routeLead(
|
|
|
424
515
|
};
|
|
425
516
|
}
|
|
426
517
|
|
|
427
|
-
//
|
|
518
|
+
// In production the file fallback is not durable (a serverless instance
|
|
519
|
+
// does not outlive the request), so reporting it as success would lose
|
|
520
|
+
// the lead silently. Deliver by email when that is configured; otherwise
|
|
521
|
+
// fail loudly (#6730 D2). A newsletter signup is never emailed — one
|
|
522
|
+
// owner email per address is not a list (`acceptsKind`).
|
|
523
|
+
if (process.env.NODE_ENV === "production") {
|
|
524
|
+
const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
|
|
525
|
+
if (input.kind === "subscribe") {
|
|
526
|
+
return {
|
|
527
|
+
ok: false,
|
|
528
|
+
error: { kind: "persist_failed", message: `db: ${dbMsg}` },
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
const emailed = await deliverByEmail(input);
|
|
532
|
+
if (!emailed.ok) {
|
|
533
|
+
return {
|
|
534
|
+
ok: false,
|
|
535
|
+
error: {
|
|
536
|
+
kind: "persist_failed",
|
|
537
|
+
// Not the provider's text: it can echo an address, and this
|
|
538
|
+
// message is logged (spec D5).
|
|
539
|
+
message: `db: ${dbMsg}; email delivery failed`,
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
await appendLeadRecord(captureRecordFor(input, emailed.id, sourceMeta));
|
|
544
|
+
return {
|
|
545
|
+
ok: true,
|
|
546
|
+
id: emailed.id,
|
|
547
|
+
persisted: "email",
|
|
548
|
+
email: emailed.email,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// 5. File fallback (outside production only)
|
|
428
553
|
// Include a UUID suffix so concurrent fallbacks produce unique IDs even
|
|
429
554
|
// within the same millisecond. Format still matches /^[a-z0-9_-]{8,64}$/i.
|
|
430
555
|
const fallbackLead = {
|
|
@@ -468,22 +593,7 @@ export async function routeLead(
|
|
|
468
593
|
// 6. Write to lead-capture JSONL (always-on canonical AX surface).
|
|
469
594
|
// Best-effort — a write failure does NOT roll back persistence or block email.
|
|
470
595
|
// The file is the agent-readable surface; DB is the transactional record.
|
|
471
|
-
|
|
472
|
-
id: id!,
|
|
473
|
-
createdAt: sourceMeta.receivedAt,
|
|
474
|
-
source: input.kind === "callback" ? "callback" : input.source,
|
|
475
|
-
data:
|
|
476
|
-
input.kind === "callback"
|
|
477
|
-
? { name: input.name, phone: input.phone, ...(input.payload ?? {}) }
|
|
478
|
-
: input.kind === "subscribe"
|
|
479
|
-
? { email: input.email }
|
|
480
|
-
: { email: input.email, ...(input.payload ?? {}) },
|
|
481
|
-
metadata: {
|
|
482
|
-
ip: sourceMeta.ip,
|
|
483
|
-
userAgent: sourceMeta.userAgent,
|
|
484
|
-
},
|
|
485
|
-
};
|
|
486
|
-
await appendLeadRecord(captureRecord);
|
|
596
|
+
await appendLeadRecord(captureRecordFor(input, id!, sourceMeta));
|
|
487
597
|
|
|
488
598
|
// 7. Email notification (best-effort — never rolls back persistence)
|
|
489
599
|
let emailResult: EmailResult;
|
|
@@ -25,10 +25,14 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Two preconditions, neither of which this file can assert:
|
|
27
27
|
*
|
|
28
|
-
* 1.
|
|
29
|
-
* `multi-tenant` module
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* 1. The caller's scope must resolve to a workspace. With the
|
|
29
|
+
* `multi-tenant` module that is `resolveOwnerWorkspaces`. Without it
|
|
30
|
+
* (#6730 D7) — Free and Pro — that resolver yields none by design (its
|
|
31
|
+
* own header, #5677), so the scope is instead the default workspace,
|
|
32
|
+
* granted ONLY through `resolveDefaultTenantForOperator`
|
|
33
|
+
* (`default-tenant.ts`): `AILK_OPERATOR_EMAIL` set, the caller's
|
|
34
|
+
* VERIFIED email equal to it, and the deployment unambiguously
|
|
35
|
+
* one-workspace. Any other caller gets the empty page, never a wider one.
|
|
32
36
|
* 2. The `siteId` the leads were WRITTEN under must equal a workspace id,
|
|
33
37
|
* and there are TWO independent sources of it — both must agree with the
|
|
34
38
|
* workspace, and neither is `currentSiteId()` on the primary path:
|
|
@@ -67,8 +71,10 @@
|
|
|
67
71
|
import type { Prisma } from "@working-theory/database";
|
|
68
72
|
import type { Id } from "@working-theory/ids";
|
|
69
73
|
|
|
74
|
+
import { isModuleEnabled } from "../config/modules.js";
|
|
70
75
|
import { prisma } from "../lib/prisma.js";
|
|
71
76
|
|
|
77
|
+
import { resolveDefaultTenantForOperator } from "./default-tenant.js";
|
|
72
78
|
import { resolveOwnerWorkspaces } from "./owner-workspaces.js";
|
|
73
79
|
|
|
74
80
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
@@ -178,6 +184,40 @@ export function readName(payload: Prisma.JsonValue): string | null {
|
|
|
178
184
|
return typeof name === "string" ? name.slice(0, MAX_NAME_LENGTH) : null;
|
|
179
185
|
}
|
|
180
186
|
|
|
187
|
+
// ─── Scope ────────────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The workspace ids whose leads the caller may read. With `multi-tenant`,
|
|
191
|
+
* every workspace they own or administer (`resolveOwnerWorkspaces`,
|
|
192
|
+
* unchanged). Without it (#6730 D7), the default workspace — only for the
|
|
193
|
+
* pinned, verified operator on a one-workspace deployment; `null` from the
|
|
194
|
+
* resolver means no scope at all.
|
|
195
|
+
*/
|
|
196
|
+
async function resolveInboxSiteIds(
|
|
197
|
+
userId: Id<"user">,
|
|
198
|
+
): Promise<{ ok: true; siteIds: string[] } | { ok: false; message: string }> {
|
|
199
|
+
if (!isModuleEnabled("multi-tenant")) {
|
|
200
|
+
try {
|
|
201
|
+
const tenant = await resolveDefaultTenantForOperator(userId);
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
siteIds: tenant && LEAD_INBOX_ROLES.has(tenant.role) ? [tenant.workspaceId] : [],
|
|
205
|
+
};
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return { ok: false, message: err instanceof Error ? err.message : String(err) };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const workspaces = await resolveOwnerWorkspaces(userId);
|
|
212
|
+
if (!workspaces.ok) return { ok: false, message: workspaces.error.message };
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
siteIds: workspaces.value
|
|
216
|
+
.filter((workspace) => LEAD_INBOX_ROLES.has(workspace.role))
|
|
217
|
+
.map((workspace) => workspace.id),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
181
221
|
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
182
222
|
|
|
183
223
|
/**
|
|
@@ -199,17 +239,14 @@ export async function listOwnedLeads(params: {
|
|
|
199
239
|
cursor?: string | undefined;
|
|
200
240
|
limit: number;
|
|
201
241
|
}): Promise<ListLeadsResult> {
|
|
202
|
-
const
|
|
203
|
-
if (!
|
|
242
|
+
const scope = await resolveInboxSiteIds(params.userId);
|
|
243
|
+
if (!scope.ok) {
|
|
204
244
|
return {
|
|
205
245
|
ok: false,
|
|
206
|
-
error: { kind: "internal_error", message:
|
|
246
|
+
error: { kind: "internal_error", message: scope.message },
|
|
207
247
|
};
|
|
208
248
|
}
|
|
209
|
-
|
|
210
|
-
const siteIds = workspaces.value
|
|
211
|
-
.filter((workspace) => LEAD_INBOX_ROLES.has(workspace.role))
|
|
212
|
-
.map((workspace) => workspace.id);
|
|
249
|
+
const siteIds = scope.siteIds;
|
|
213
250
|
|
|
214
251
|
// No workspace the caller owns or administers means no lead can match, so
|
|
215
252
|
// there is nothing to query. An empty page is the honest answer — the same
|