create-ailk 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -8
- package/component-catalog.json +336 -27
- package/component-catalog.md +25 -12
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/module-architecture.d.ts +1 -0
- package/dist/module-architecture.js +253 -127
- package/dist/surfaces.js +105 -7
- package/dist/template-manifest.d.ts +33 -0
- package/dist/template-manifest.js +99 -16
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +1 -0
- package/package.json +3 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
- package/site-templates/marketing-site/ailk-template.json +1 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
- package/site-templates/minimalism-site/ailk-template.json +2 -1
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.env.example +72 -7
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
- package/templates/apps/api/.env.example +13 -0
- package/templates/apps/api/CLAUDE.md +26 -2
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
- package/templates/apps/api/src/__tests__/server.test.ts +471 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
- package/templates/apps/api/src/config/index.ts +62 -0
- package/templates/apps/api/src/config/modules.ts +7 -3
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
- package/templates/apps/api/src/lib/content-adapter.ts +57 -31
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
- package/templates/apps/api/src/lib/error-serializer.ts +23 -3
- package/templates/apps/api/src/lib/outbound-http.ts +7 -2
- package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
- package/templates/apps/api/src/lib/site.ts +208 -9
- package/templates/apps/api/src/lib/stripe.ts +66 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
- package/templates/apps/api/src/openapi/spec.ts +543 -2
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
- package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
- package/templates/apps/api/src/routes/auth/user.ts +61 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
- package/templates/apps/api/src/routes/checkout/index.ts +34 -0
- package/templates/apps/api/src/routes/checkout/session.ts +139 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
- package/templates/apps/api/src/routes/content/get-page.ts +94 -12
- package/templates/apps/api/src/routes/content/index.ts +13 -73
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
- package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
- package/templates/apps/api/src/routes/content/search.ts +129 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
- package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
- package/templates/apps/api/src/routes/requests/index.ts +54 -0
- package/templates/apps/api/src/routes/webhooks/README.md +265 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
- package/templates/apps/api/src/server.ts +439 -49
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
- package/templates/apps/api/src/services/abandon-email.ts +8 -2
- package/templates/apps/api/src/services/aeo-score.ts +49 -12
- package/templates/apps/api/src/services/audit-log.ts +668 -0
- package/templates/apps/api/src/services/consent.ts +18 -0
- package/templates/apps/api/src/services/default-tenant.ts +70 -31
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
- package/templates/apps/api/src/services/dunning-policy.ts +294 -0
- package/templates/apps/api/src/services/entitlements.ts +80 -0
- package/templates/apps/api/src/services/lead-promotion.ts +13 -14
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +23 -2
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/project-listings.ts +201 -6
- package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
- package/templates/apps/api/src/services/request-confirm.ts +646 -0
- package/templates/apps/api/src/services/revalidation.ts +173 -0
- package/templates/apps/api/src/services/verified-email.ts +71 -0
- package/templates/apps/api/src/vercel-handler.ts +75 -35
- package/templates/apps/mcp/.env.example +17 -0
- package/templates/apps/mcp/CLAUDE.md +57 -24
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
- package/templates/apps/mcp/__tests__/index.test.ts +39 -0
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
- package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
- package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
- package/templates/apps/mcp/__tests__/server.test.ts +67 -14
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +2 -0
- package/templates/apps/mcp/src/abuse/index.ts +159 -24
- package/templates/apps/mcp/src/config/modules.ts +10 -5
- package/templates/apps/mcp/src/http-transport.ts +102 -29
- package/templates/apps/mcp/src/index.ts +73 -11
- package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
- package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
- package/templates/apps/mcp/src/lib/prisma.ts +39 -0
- package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
- package/templates/apps/mcp/src/server.ts +59 -35
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
- package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
- package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
- package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
- package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
- package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
- package/templates/apps/mcp/src/tools/index.ts +6 -4
- package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
- package/templates/apps/mcp/src/tools/purchase.ts +128 -45
- package/templates/apps/mcp/src/tools/read_content.ts +20 -6
- package/templates/apps/mcp/src/tools/search_content.ts +11 -4
- package/templates/apps/web/.env.example +71 -5
- package/templates/apps/web/CLAUDE.md +44 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/layout.tsx +133 -87
- package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
- package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
- package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
- package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
- package/templates/apps/web/app/api/revalidate/route.ts +194 -0
- package/templates/apps/web/auth.ts +407 -8
- package/templates/apps/web/jest.config.cjs +8 -4
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
- package/templates/apps/web/lib/auth-email.ts +318 -0
- package/templates/apps/web/lib/auth-env.ts +85 -0
- package/templates/apps/web/lib/auth-routes.ts +55 -0
- package/templates/apps/web/lib/meeting-slugs.ts +53 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +187 -0
- package/templates/apps/web/package.json +2 -1
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +142 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/tenancy.test.ts +141 -0
- package/templates/database/config/schema.prisma +79 -2
- package/templates/database/content/schema.prisma +87 -11
- package/templates/database/inbox/schema.prisma +452 -9
- package/templates/database/jest.config.cjs +5 -3
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
- package/templates/database/package.json +1 -1
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/versions.json +34 -34
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
- package/templates/apps/api/src/routes/content/create.ts +0 -115
- package/templates/apps/api/src/routes/content/delete.ts +0 -82
- package/templates/apps/api/src/routes/content/update.ts +0 -112
- package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
- package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
- package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
- package/templates/apps/mcp/src/tools/create_page.ts +0 -96
- package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
- package/templates/apps/mcp/src/tools/update_page.ts +0 -92
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
|
@@ -31,6 +31,15 @@ jest.mock("@working-theory/email", () => ({
|
|
|
31
31
|
ok: true,
|
|
32
32
|
messageId: "msg-test",
|
|
33
33
|
}),
|
|
34
|
+
// #5970 — the request-and-confirm tenant sender. Declared here because the
|
|
35
|
+
// callback route now reaches it, and because without it the feature-ON cases
|
|
36
|
+
// below would pass for the wrong reason: with the flag unset,
|
|
37
|
+
// `sendRequestConfirmLink` no-ops before it ever touches this import.
|
|
38
|
+
sendRequestConfirmEmail: jest.fn().mockResolvedValue({
|
|
39
|
+
ok: true,
|
|
40
|
+
messageId: "msg-confirm",
|
|
41
|
+
}),
|
|
42
|
+
sendRequestPaidLinkEmail: jest.fn(),
|
|
34
43
|
}));
|
|
35
44
|
|
|
36
45
|
// @working-theory/license: consumed transitively by enrichment-shim; mocked to avoid
|
|
@@ -50,7 +59,12 @@ import {
|
|
|
50
59
|
leadSubscribeRoute,
|
|
51
60
|
leadCallbackRoute,
|
|
52
61
|
} from "../index";
|
|
62
|
+
import {
|
|
63
|
+
mockNoWorkspace,
|
|
64
|
+
mockSentinelWorkspaceExists,
|
|
65
|
+
} from "../../../lib/__mocks__/prisma.js";
|
|
53
66
|
import { prisma } from "../../../lib/prisma.js";
|
|
67
|
+
import { __resetSiteWorkspaceCacheForTests } from "../../../lib/site.js";
|
|
54
68
|
import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
|
|
55
69
|
// #3979 AC1 — the single shared fixture (packages/validation, which apps/api
|
|
56
70
|
// may_import_from per architecture.yaml) for the NewsletterFormInline
|
|
@@ -68,6 +82,42 @@ import {
|
|
|
68
82
|
|
|
69
83
|
const fileEnv = setupLeadFileEnv("index");
|
|
70
84
|
|
|
85
|
+
// #6203 — every lead-capture kind now resolves its tenant SERVER-SIDE
|
|
86
|
+
// (routes/leads/index.ts), never from a body-supplied `siteId`. A test's
|
|
87
|
+
// body may still send a `siteId` (a client-embed artifact, or a spoof
|
|
88
|
+
// attempt) but it is silently ignored — the SEEDED default workspace id
|
|
89
|
+
// (`database/tenancy.ts DEFAULT_WORKSPACE_ID`) is what actually lands, on
|
|
90
|
+
// both `siteId` and the new `workspaceId` column.
|
|
91
|
+
//
|
|
92
|
+
// #6335 — that resolution used to come off the shared prisma mock's
|
|
93
|
+
// PERMISSIVE `workspace.findUnique` default, which resolved any id it was
|
|
94
|
+
// handed. The default is gone, so this suite states the workspace it
|
|
95
|
+
// expects: `stateSentinelWorkspaceExists()` below, called wherever this
|
|
96
|
+
// file clears its mocks.
|
|
97
|
+
const RESOLVED_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A second, real workspace the deployment is NOT serving. The three spoof
|
|
101
|
+
* cases below make it resolvable for their own duration, so "the row did
|
|
102
|
+
* not land there" is a statement about what the route READ rather than a
|
|
103
|
+
* side effect of the id resolving to nothing.
|
|
104
|
+
*/
|
|
105
|
+
const FOREIGN_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-00000000dead";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* #6335 — the ONE workspace every case in this file expects to exist: the
|
|
109
|
+
* seeded sentinel `currentSiteId()`'s "default" alias names. Keyed on the
|
|
110
|
+
* requested id inside the helper, so a body-supplied foreign `siteId`
|
|
111
|
+
* resolves to nothing — which is what makes the spoof cases below prove
|
|
112
|
+
* the route never reads it, rather than proving a permissive mock resolves
|
|
113
|
+
* everything. The resolver's memo is cleared alongside, so no case answers
|
|
114
|
+
* out of a previous one's successful resolution.
|
|
115
|
+
*/
|
|
116
|
+
function stateSentinelWorkspaceExists(): void {
|
|
117
|
+
__resetSiteWorkspaceCacheForTests();
|
|
118
|
+
mockSentinelWorkspaceExists(prisma);
|
|
119
|
+
}
|
|
120
|
+
|
|
71
121
|
// ─── Suite setup ──────────────────────────────────────────────────────────────
|
|
72
122
|
|
|
73
123
|
describe("POST /api/leads — id isolation", () => {
|
|
@@ -75,6 +125,7 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
75
125
|
|
|
76
126
|
beforeEach(async () => {
|
|
77
127
|
jest.clearAllMocks();
|
|
128
|
+
stateSentinelWorkspaceExists();
|
|
78
129
|
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
79
130
|
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
80
131
|
process.env.DATABASE_URL = "postgres://stub";
|
|
@@ -97,7 +148,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
97
148
|
|
|
98
149
|
it("POST /api/leads/submit — attacker-supplied id is not returned (server uuid used)", async () => {
|
|
99
150
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a1";
|
|
100
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
151
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
152
|
+
id: serverGeneratedId,
|
|
153
|
+
});
|
|
101
154
|
|
|
102
155
|
const res = await app.inject({
|
|
103
156
|
method: "POST",
|
|
@@ -124,7 +177,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
124
177
|
|
|
125
178
|
it("POST /api/leads/subscribe — attacker-supplied id is not returned (server uuid used)", async () => {
|
|
126
179
|
const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000a2";
|
|
127
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
180
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
181
|
+
id: serverGeneratedId,
|
|
182
|
+
});
|
|
128
183
|
|
|
129
184
|
const res = await app.inject({
|
|
130
185
|
method: "POST",
|
|
@@ -154,7 +209,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
154
209
|
// values), with nothing catching it.
|
|
155
210
|
it("POST /v1/leads/subscribe — the block's emitted JSON shape ({ siteId, source, email }) is accepted and persisted", async () => {
|
|
156
211
|
const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000b1";
|
|
157
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
212
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
213
|
+
id: serverGeneratedId,
|
|
214
|
+
});
|
|
158
215
|
|
|
159
216
|
// The exact body NewsletterFormInline emits at its defaults (#3965 D1) —
|
|
160
217
|
// no `kind` key: buildInput() injects `kind` server-side from the route,
|
|
@@ -185,7 +242,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
185
242
|
// the id-isolation walls hold regardless of validation outcome.
|
|
186
243
|
it("POST /v1/leads/submit — the agency-waitlist instance's emitted JSON shape ({ siteId, source, email }) is accepted and persisted", async () => {
|
|
187
244
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000b2";
|
|
188
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
245
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
246
|
+
id: serverGeneratedId,
|
|
247
|
+
});
|
|
189
248
|
|
|
190
249
|
// The exact body NewsletterFormInline emits for the agency-waitlist
|
|
191
250
|
// instance — no `kind`, no `payload` key: buildInput() injects `kind`
|
|
@@ -217,7 +276,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
217
276
|
// this layer asserting a stale contract.
|
|
218
277
|
it("POST /v1/leads/submit — the ContactForm section's emitted JSON shape ({ siteId, source, email, payload }) is accepted and persisted", async () => {
|
|
219
278
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000b3";
|
|
220
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
279
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
280
|
+
id: serverGeneratedId,
|
|
281
|
+
});
|
|
221
282
|
|
|
222
283
|
// The exact body ContactForm emits at its component defaults (#4017 D1) —
|
|
223
284
|
// no `kind` key: buildInput() injects `kind` server-side from the route,
|
|
@@ -236,7 +297,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
236
297
|
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
237
298
|
expect(prisma.lead.create).toHaveBeenCalledWith({
|
|
238
299
|
data: {
|
|
239
|
-
|
|
300
|
+
// #6203 — the RESOLVED workspace, never the body's own `siteId`.
|
|
301
|
+
siteId: RESOLVED_WORKSPACE_ID,
|
|
302
|
+
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
240
303
|
source: CONTACT_FORM_LEAD_BODY.source,
|
|
241
304
|
email: CONTACT_FORM_LEAD_BODY.email,
|
|
242
305
|
payload: CONTACT_FORM_LEAD_BODY.payload,
|
|
@@ -254,7 +317,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
254
317
|
|
|
255
318
|
it("POST /api/leads/callback — attacker-supplied id is not returned (server uuid used)", async () => {
|
|
256
319
|
const serverGeneratedId = "callbk_00000000-0000-7000-8000-0000000000a3";
|
|
257
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
320
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
321
|
+
id: serverGeneratedId,
|
|
322
|
+
});
|
|
258
323
|
|
|
259
324
|
const res = await app.inject({
|
|
260
325
|
method: "POST",
|
|
@@ -281,7 +346,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
281
346
|
|
|
282
347
|
it("file-on-success — appendLeadRecord writes to LEAD_CAPTURE_FILE on successful DB persist", async () => {
|
|
283
348
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a4";
|
|
284
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
349
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
350
|
+
id: serverGeneratedId,
|
|
351
|
+
});
|
|
285
352
|
|
|
286
353
|
const res = await app.inject({
|
|
287
354
|
method: "POST",
|
|
@@ -306,7 +373,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
306
373
|
|
|
307
374
|
it("dual-write — real lead-capture-store AND DB are both invoked on success", async () => {
|
|
308
375
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a5";
|
|
309
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
376
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
377
|
+
id: serverGeneratedId,
|
|
378
|
+
});
|
|
310
379
|
|
|
311
380
|
const res = await app.inject({
|
|
312
381
|
method: "POST",
|
|
@@ -331,7 +400,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
331
400
|
process.env.LEAD_CAPTURE_FILE_DISABLED = "true";
|
|
332
401
|
|
|
333
402
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a6";
|
|
334
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
403
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
404
|
+
id: serverGeneratedId,
|
|
405
|
+
});
|
|
335
406
|
|
|
336
407
|
const res = await app.inject({
|
|
337
408
|
method: "POST",
|
|
@@ -349,9 +420,7 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
349
420
|
if (res.statusCode === 201) {
|
|
350
421
|
// The real lead-capture-store short-circuits when LEAD_CAPTURE_FILE_DISABLED=true.
|
|
351
422
|
// The capture file must not be written.
|
|
352
|
-
await expect(
|
|
353
|
-
fs.access(fileEnv.getCaptureFilePath()),
|
|
354
|
-
).rejects.toThrow();
|
|
423
|
+
await expect(fs.access(fileEnv.getCaptureFilePath())).rejects.toThrow();
|
|
355
424
|
}
|
|
356
425
|
});
|
|
357
426
|
|
|
@@ -381,7 +450,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
381
450
|
it("IP-include — metadata.ip present in capture record when LEAD_CAPTURE_INCLUDE_IP=true", async () => {
|
|
382
451
|
process.env.LEAD_CAPTURE_INCLUDE_IP = "true";
|
|
383
452
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a7";
|
|
384
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
453
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
454
|
+
id: serverGeneratedId,
|
|
455
|
+
});
|
|
385
456
|
|
|
386
457
|
const res = await app.inject({
|
|
387
458
|
method: "POST",
|
|
@@ -398,7 +469,7 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
398
469
|
|
|
399
470
|
if (res.statusCode === 201) {
|
|
400
471
|
const record = await fileEnv.readLastCaptureRecord();
|
|
401
|
-
expect(
|
|
472
|
+
expect(record.metadata as Record<string, unknown>).toHaveProperty("ip");
|
|
402
473
|
}
|
|
403
474
|
});
|
|
404
475
|
|
|
@@ -406,7 +477,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
406
477
|
|
|
407
478
|
it("lead — payload absent from body defaults to empty object via ?? (payload ?? {} branch)", async () => {
|
|
408
479
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000a8";
|
|
409
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
480
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
481
|
+
id: serverGeneratedId,
|
|
482
|
+
});
|
|
410
483
|
|
|
411
484
|
const bodyWithoutPayload: Record<string, unknown> = {
|
|
412
485
|
kind: "submit",
|
|
@@ -430,7 +503,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
430
503
|
|
|
431
504
|
it("callback — preferredTime included when provided (preferredTime spread branch)", async () => {
|
|
432
505
|
const serverGeneratedId = "callbk_00000000-0000-7000-8000-0000000000a9";
|
|
433
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
506
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
507
|
+
id: serverGeneratedId,
|
|
508
|
+
});
|
|
434
509
|
|
|
435
510
|
const res = await app.inject({
|
|
436
511
|
method: "POST",
|
|
@@ -453,7 +528,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
453
528
|
|
|
454
529
|
it("callback — payload absent from body defaults to empty object via ?? (callback payload ?? {} branch)", async () => {
|
|
455
530
|
const serverGeneratedId = "callbk_00000000-0000-7000-8000-0000000000aa";
|
|
456
|
-
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
531
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
532
|
+
id: serverGeneratedId,
|
|
533
|
+
});
|
|
457
534
|
|
|
458
535
|
const bodyWithoutPayload: Record<string, unknown> = {
|
|
459
536
|
kind: "callback",
|
|
@@ -481,7 +558,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
481
558
|
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
482
559
|
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
483
560
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000ab";
|
|
484
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
561
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
562
|
+
id: serverGeneratedId,
|
|
563
|
+
});
|
|
485
564
|
|
|
486
565
|
const res = await app.inject({
|
|
487
566
|
method: "POST",
|
|
@@ -511,7 +590,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
511
590
|
it("envelope submit — { siteId, source, payload: { email, ... } } returns 201, writes one Lead with that email and the full payload, and routes the notification email", async () => {
|
|
512
591
|
const { sendLeadNotification } = jest.requireMock("@working-theory/email");
|
|
513
592
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000e1";
|
|
514
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
593
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
594
|
+
id: serverGeneratedId,
|
|
595
|
+
});
|
|
515
596
|
|
|
516
597
|
const envelopePayload = {
|
|
517
598
|
email: "envelope@example.com",
|
|
@@ -535,7 +616,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
535
616
|
expect(prisma.lead.create).toHaveBeenCalledTimes(1);
|
|
536
617
|
expect(prisma.lead.create).toHaveBeenCalledWith({
|
|
537
618
|
data: {
|
|
538
|
-
|
|
619
|
+
// #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
|
|
620
|
+
siteId: RESOLVED_WORKSPACE_ID,
|
|
621
|
+
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
539
622
|
source: "step-flow",
|
|
540
623
|
email: "envelope@example.com",
|
|
541
624
|
payload: envelopePayload,
|
|
@@ -548,7 +631,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
548
631
|
|
|
549
632
|
it("envelope subscribe — { siteId, source, payload: { email } } returns 201 and writes one Subscriber", async () => {
|
|
550
633
|
const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000e2";
|
|
551
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
634
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
635
|
+
id: serverGeneratedId,
|
|
636
|
+
});
|
|
552
637
|
|
|
553
638
|
const res = await app.inject({
|
|
554
639
|
method: "POST",
|
|
@@ -566,7 +651,14 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
566
651
|
expect(prisma.subscriber.create).toHaveBeenCalledTimes(1);
|
|
567
652
|
expect(prisma.subscriber.create).toHaveBeenCalledWith({
|
|
568
653
|
data: {
|
|
569
|
-
|
|
654
|
+
// #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
|
|
655
|
+
siteId: RESOLVED_WORKSPACE_ID,
|
|
656
|
+
// #6374 — `subscribers.workspace_id` is NOT NULL as of
|
|
657
|
+
// database/migrations/20260922120000_tenancy_fence, and
|
|
658
|
+
// `subscribers_workspace_site_agree` forces it to EQUAL `site_id`.
|
|
659
|
+
// Asserted as the same RESOLVED value, so a raw alias leaking into
|
|
660
|
+
// either column fails this test rather than the database.
|
|
661
|
+
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
570
662
|
email: "sub-envelope@example.com",
|
|
571
663
|
source: "step-flow",
|
|
572
664
|
},
|
|
@@ -610,7 +702,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
610
702
|
|
|
611
703
|
it("submit — a top-level email wins over a payload email", async () => {
|
|
612
704
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000e3";
|
|
613
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
705
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
706
|
+
id: serverGeneratedId,
|
|
707
|
+
});
|
|
614
708
|
|
|
615
709
|
const res = await app.inject({
|
|
616
710
|
method: "POST",
|
|
@@ -626,7 +720,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
626
720
|
expect(res.statusCode).toBe(201);
|
|
627
721
|
expect(prisma.lead.create).toHaveBeenCalledWith({
|
|
628
722
|
data: {
|
|
629
|
-
|
|
723
|
+
// #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
|
|
724
|
+
siteId: RESOLVED_WORKSPACE_ID,
|
|
725
|
+
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
630
726
|
source: "step-flow",
|
|
631
727
|
email: "top-level@example.com",
|
|
632
728
|
payload: { email: "shadowed-payload@example.com", message: "hi" },
|
|
@@ -637,7 +733,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
637
733
|
|
|
638
734
|
it("subscribe — a top-level email wins over a payload email", async () => {
|
|
639
735
|
const serverGeneratedId = "subscr_00000000-0000-7000-8000-0000000000e4";
|
|
640
|
-
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
736
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
737
|
+
id: serverGeneratedId,
|
|
738
|
+
});
|
|
641
739
|
|
|
642
740
|
const res = await app.inject({
|
|
643
741
|
method: "POST",
|
|
@@ -653,7 +751,11 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
653
751
|
expect(res.statusCode).toBe(201);
|
|
654
752
|
expect(prisma.subscriber.create).toHaveBeenCalledWith({
|
|
655
753
|
data: {
|
|
656
|
-
|
|
754
|
+
// #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
|
|
755
|
+
siteId: RESOLVED_WORKSPACE_ID,
|
|
756
|
+
// #6374 — equal to `siteId` by construction; see the note on the
|
|
757
|
+
// envelope-subscribe assertion above.
|
|
758
|
+
workspaceId: RESOLVED_WORKSPACE_ID,
|
|
657
759
|
email: "top-level-sub@example.com",
|
|
658
760
|
source: "step-flow",
|
|
659
761
|
},
|
|
@@ -668,7 +770,9 @@ describe("POST /api/leads — id isolation", () => {
|
|
|
668
770
|
error: { kind: "send_error", message: "SMTP timeout" },
|
|
669
771
|
});
|
|
670
772
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000ac";
|
|
671
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
773
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
774
|
+
id: serverGeneratedId,
|
|
775
|
+
});
|
|
672
776
|
|
|
673
777
|
const res = await app.inject({
|
|
674
778
|
method: "POST",
|
|
@@ -702,12 +806,15 @@ describe("POST /v1/leads/submit — structured log stays PII-safe for envelope i
|
|
|
702
806
|
|
|
703
807
|
it("the 'lead routed' log line carries no email or payload value when email is lifted from payload", async () => {
|
|
704
808
|
jest.clearAllMocks();
|
|
809
|
+
stateSentinelWorkspaceExists();
|
|
705
810
|
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
706
811
|
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
707
812
|
process.env.DATABASE_URL = "postgres://stub";
|
|
708
813
|
|
|
709
814
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000e5";
|
|
710
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
815
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
816
|
+
id: serverGeneratedId,
|
|
817
|
+
});
|
|
711
818
|
|
|
712
819
|
const logLines: string[] = [];
|
|
713
820
|
const logCapture = require("fastify")({
|
|
@@ -744,7 +851,8 @@ describe("POST /v1/leads/submit — structured log stays PII-safe for envelope i
|
|
|
744
851
|
expect(logged).not.toHaveProperty("email");
|
|
745
852
|
expect(logged).not.toHaveProperty("payload");
|
|
746
853
|
expect(logged).toMatchObject({
|
|
747
|
-
|
|
854
|
+
// #6203 — the RESOLVED workspace; the body's "site-abc" is ignored.
|
|
855
|
+
siteId: RESOLVED_WORKSPACE_ID,
|
|
748
856
|
kind: "submit",
|
|
749
857
|
persisted: "db",
|
|
750
858
|
});
|
|
@@ -754,6 +862,164 @@ describe("POST /v1/leads/submit — structured log stays PII-safe for envelope i
|
|
|
754
862
|
});
|
|
755
863
|
});
|
|
756
864
|
|
|
865
|
+
// ── #6203 AC-4 — tenant resolution: per-kind, unresolved-rejects, spoof-proof ──
|
|
866
|
+
//
|
|
867
|
+
// Every capture kind resolves its tenant SERVER-SIDE (routes/leads/index.ts's
|
|
868
|
+
// header). These tests assert the two halves that matter: (1) an unresolved
|
|
869
|
+
// resolution — DATABASE_URL set, but the alias names no workspace this
|
|
870
|
+
// database holds — rejects the write with a 503 BEFORE any create call, for
|
|
871
|
+
// each of the three kinds; (2) a client-supplied `siteId` naming a DIFFERENT,
|
|
872
|
+
// otherwise-resolvable workspace never places a row there — the resolved
|
|
873
|
+
// value always wins, proving the body value is truly ignored rather than
|
|
874
|
+
// merely unpreferred.
|
|
875
|
+
|
|
876
|
+
describe("POST /api/leads/:kind — tenant resolution (#6203 AC-4)", () => {
|
|
877
|
+
let app: FastifyInstance;
|
|
878
|
+
|
|
879
|
+
beforeEach(async () => {
|
|
880
|
+
jest.clearAllMocks();
|
|
881
|
+
stateSentinelWorkspaceExists();
|
|
882
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
883
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
884
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
885
|
+
delete process.env.AILK_SITE_ID;
|
|
886
|
+
|
|
887
|
+
const fastify = require("fastify")();
|
|
888
|
+
await fastify.register(leadSubmitRoute);
|
|
889
|
+
await fastify.register(leadSubscribeRoute);
|
|
890
|
+
await fastify.register(leadCallbackRoute);
|
|
891
|
+
app = fastify;
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
afterEach(async () => {
|
|
895
|
+
await app.close();
|
|
896
|
+
__resetSiteWorkspaceCacheForTests();
|
|
897
|
+
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
898
|
+
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
899
|
+
delete process.env.DATABASE_URL;
|
|
900
|
+
delete process.env.AILK_SITE_ID;
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("submit: an unresolved workspace is a 503 tenant_unresolved and writes no Lead", async () => {
|
|
904
|
+
mockNoWorkspace(prisma);
|
|
905
|
+
|
|
906
|
+
const res = await app.inject({
|
|
907
|
+
method: "POST",
|
|
908
|
+
url: "/v1/leads/submit",
|
|
909
|
+
payload: {
|
|
910
|
+
siteId: "default",
|
|
911
|
+
source: "contact-form",
|
|
912
|
+
email: "nobody@example.com",
|
|
913
|
+
payload: {},
|
|
914
|
+
},
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
expect(res.statusCode).toBe(503);
|
|
918
|
+
expect(res.json()).toMatchObject({ error: "tenant_unresolved" });
|
|
919
|
+
expect(prisma.lead.create).not.toHaveBeenCalled();
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it("subscribe: an unresolved workspace is a 503 tenant_unresolved and writes no Subscriber", async () => {
|
|
923
|
+
mockNoWorkspace(prisma);
|
|
924
|
+
|
|
925
|
+
const res = await app.inject({
|
|
926
|
+
method: "POST",
|
|
927
|
+
url: "/v1/leads/subscribe",
|
|
928
|
+
payload: { siteId: "default", source: "footer", email: "nobody@example.com" },
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
expect(res.statusCode).toBe(503);
|
|
932
|
+
expect(res.json()).toMatchObject({ error: "tenant_unresolved" });
|
|
933
|
+
expect(prisma.subscriber.create).not.toHaveBeenCalled();
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("callback: an unresolved workspace is a 503 tenant_unresolved and writes no Callback", async () => {
|
|
937
|
+
mockNoWorkspace(prisma);
|
|
938
|
+
|
|
939
|
+
const res = await app.inject({
|
|
940
|
+
method: "POST",
|
|
941
|
+
url: "/v1/leads/callback",
|
|
942
|
+
payload: { siteId: "default", name: "Nobody", phone: "+15550000000" },
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
expect(res.statusCode).toBe(503);
|
|
946
|
+
expect(res.json()).toMatchObject({ error: "tenant_unresolved" });
|
|
947
|
+
expect(prisma.callback.create).not.toHaveBeenCalled();
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("submit: a body siteId naming another workspace never places the row there — the resolved workspace always wins", async () => {
|
|
951
|
+
mockSentinelWorkspaceExists(prisma, FOREIGN_WORKSPACE_ID);
|
|
952
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
953
|
+
id: "lead_00000000-0000-7000-8000-0000000000f1",
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
const res = await app.inject({
|
|
957
|
+
method: "POST",
|
|
958
|
+
url: "/v1/leads/submit",
|
|
959
|
+
payload: {
|
|
960
|
+
// A DIFFERENT workspace id, which this test has deliberately made
|
|
961
|
+
// RESOLVABLE (`FOREIGN_WORKSPACE_ID` above) — so the row landing on
|
|
962
|
+
// the sentinel proves buildInput() never reads the body value at
|
|
963
|
+
// all, rather than proving the read merely failed closed.
|
|
964
|
+
siteId: FOREIGN_WORKSPACE_ID,
|
|
965
|
+
source: "contact-form",
|
|
966
|
+
email: "spoof@example.com",
|
|
967
|
+
payload: {},
|
|
968
|
+
},
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
expect(res.statusCode).toBe(201);
|
|
972
|
+
const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
|
|
973
|
+
expect(data.siteId).toBe(RESOLVED_WORKSPACE_ID);
|
|
974
|
+
expect(data.workspaceId).toBe(RESOLVED_WORKSPACE_ID);
|
|
975
|
+
expect(data.siteId).not.toBe(FOREIGN_WORKSPACE_ID);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it("subscribe: a body siteId naming another workspace never places the row there", async () => {
|
|
979
|
+
mockSentinelWorkspaceExists(prisma, FOREIGN_WORKSPACE_ID);
|
|
980
|
+
(prisma.subscriber.create as jest.Mock).mockResolvedValue({
|
|
981
|
+
id: "subscr_00000000-0000-7000-8000-0000000000f2",
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
const res = await app.inject({
|
|
985
|
+
method: "POST",
|
|
986
|
+
url: "/v1/leads/subscribe",
|
|
987
|
+
payload: {
|
|
988
|
+
siteId: FOREIGN_WORKSPACE_ID,
|
|
989
|
+
source: "footer",
|
|
990
|
+
email: "spoof-sub@example.com",
|
|
991
|
+
},
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
expect(res.statusCode).toBe(201);
|
|
995
|
+
const { data } = (prisma.subscriber.create as jest.Mock).mock.calls[0]![0];
|
|
996
|
+
expect(data.siteId).toBe(RESOLVED_WORKSPACE_ID);
|
|
997
|
+
expect(data.siteId).not.toBe(FOREIGN_WORKSPACE_ID);
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
it("callback: a body siteId naming another workspace never places the row there", async () => {
|
|
1001
|
+
mockSentinelWorkspaceExists(prisma, FOREIGN_WORKSPACE_ID);
|
|
1002
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
1003
|
+
id: "callbk_00000000-0000-7000-8000-0000000000f3",
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
const res = await app.inject({
|
|
1007
|
+
method: "POST",
|
|
1008
|
+
url: "/v1/leads/callback",
|
|
1009
|
+
payload: {
|
|
1010
|
+
siteId: FOREIGN_WORKSPACE_ID,
|
|
1011
|
+
name: "Spoofer",
|
|
1012
|
+
phone: "+15559999999",
|
|
1013
|
+
},
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
expect(res.statusCode).toBe(201);
|
|
1017
|
+
const { data } = (prisma.callback.create as jest.Mock).mock.calls[0]![0];
|
|
1018
|
+
expect(data.siteId).toBe(RESOLVED_WORKSPACE_ID);
|
|
1019
|
+
expect(data.siteId).not.toBe(FOREIGN_WORKSPACE_ID);
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
|
|
757
1023
|
// ── D5 — registration binds the client's literal path (issue #3964) ───────────
|
|
758
1024
|
//
|
|
759
1025
|
// The drift class this issue fixed: every test above hand-supplied the same
|
|
@@ -768,6 +1034,7 @@ describe("POST /v1/leads/submit — registration binds the client's literal path
|
|
|
768
1034
|
|
|
769
1035
|
beforeEach(async () => {
|
|
770
1036
|
jest.clearAllMocks();
|
|
1037
|
+
stateSentinelWorkspaceExists();
|
|
771
1038
|
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
772
1039
|
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
773
1040
|
process.env.DATABASE_URL = "postgres://stub";
|
|
@@ -786,7 +1053,9 @@ describe("POST /v1/leads/submit — registration binds the client's literal path
|
|
|
786
1053
|
|
|
787
1054
|
it("resolves POST /v1/leads/submit — the exact literal packages/api-client/src/leads.ts's leads.submit() posts to", async () => {
|
|
788
1055
|
const serverGeneratedId = "lead_00000000-0000-7000-8000-0000000000d1";
|
|
789
|
-
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1056
|
+
(prisma.lead.create as jest.Mock).mockResolvedValue({
|
|
1057
|
+
id: serverGeneratedId,
|
|
1058
|
+
});
|
|
790
1059
|
|
|
791
1060
|
// Cross-reference: packages/api-client/src/leads.ts —
|
|
792
1061
|
// submit: (payload) => post<LeadWriteResponse>("/v1/leads/submit", payload)
|
|
@@ -820,3 +1089,150 @@ describe("POST /v1/leads/submit — registration binds the client's literal path
|
|
|
820
1089
|
expect(res.statusCode).toBe(404);
|
|
821
1090
|
});
|
|
822
1091
|
});
|
|
1092
|
+
|
|
1093
|
+
// ── #5970 — the route wiring itself, at route grain ────────────────────────
|
|
1094
|
+
//
|
|
1095
|
+
// The #5970 acceptance review's one residual: the suppression half is measured
|
|
1096
|
+
// through the real `routeLead`, and the send half through the real
|
|
1097
|
+
// `sendRequestConfirmLink`, but the three lines in `routes/leads/index.ts`
|
|
1098
|
+
// that CONNECT them were asserted by construction only. Delete that call and
|
|
1099
|
+
// every other test in the branch stays green while the tenant receives zero
|
|
1100
|
+
// emails. These two cases drive the endpoint the acceptance criterion names,
|
|
1101
|
+
// so that deletion turns something red.
|
|
1102
|
+
//
|
|
1103
|
+
// NFR-1 is untouched by this block: every pre-existing case above runs with
|
|
1104
|
+
// the feature unset, exactly as before, and none of them is edited. What is
|
|
1105
|
+
// added here is the feature-ON path, which did not exist to be covered.
|
|
1106
|
+
describe("POST /v1/leads/callback — request-and-confirm wiring (#5970)", () => {
|
|
1107
|
+
// Registers its own hooks, so it must sit at describe level. It provisions
|
|
1108
|
+
// the file-fallback destination the second case needs; the first case never
|
|
1109
|
+
// reaches the fallback because its `callback.create` resolves.
|
|
1110
|
+
setupLeadFileEnv();
|
|
1111
|
+
|
|
1112
|
+
const CONFIRM_ENV = [
|
|
1113
|
+
"AILK_REQUEST_CONFIRM_ENABLED",
|
|
1114
|
+
"AILK_REQUEST_CONFIRM_TO",
|
|
1115
|
+
"AILK_REQUEST_CONFIRM_FROM",
|
|
1116
|
+
"AILK_PUBLIC_API_URL",
|
|
1117
|
+
] as const;
|
|
1118
|
+
|
|
1119
|
+
function enableConfirm() {
|
|
1120
|
+
process.env.AILK_REQUEST_CONFIRM_ENABLED = "true";
|
|
1121
|
+
process.env.AILK_REQUEST_CONFIRM_TO = "owner@example.com";
|
|
1122
|
+
process.env.AILK_REQUEST_CONFIRM_FROM = "hello@example.com";
|
|
1123
|
+
process.env.AILK_PUBLIC_API_URL = "https://api.example.com";
|
|
1124
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
1125
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
1126
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
afterEach(() => {
|
|
1130
|
+
for (const key of CONFIRM_ENV) delete process.env[key];
|
|
1131
|
+
delete process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
1132
|
+
delete process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
1133
|
+
delete process.env.DATABASE_URL;
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
/** Awaitable equivalent of the route's fire-and-forget confirm send. */
|
|
1137
|
+
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
1138
|
+
|
|
1139
|
+
async function postCallback(fastify: FastifyInstance) {
|
|
1140
|
+
return fastify.inject({
|
|
1141
|
+
method: "POST",
|
|
1142
|
+
url: "/v1/leads/callback",
|
|
1143
|
+
payload: {
|
|
1144
|
+
siteId: "site-abc",
|
|
1145
|
+
name: "Ada Lovelace",
|
|
1146
|
+
phone: "+44 7700 900000",
|
|
1147
|
+
payload: {},
|
|
1148
|
+
},
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
it("on the DB path the confirm email goes and the generic notification does not", async () => {
|
|
1153
|
+
jest.clearAllMocks();
|
|
1154
|
+
stateSentinelWorkspaceExists();
|
|
1155
|
+
enableConfirm();
|
|
1156
|
+
const email = require("@working-theory/email");
|
|
1157
|
+
(prisma.callback.create as jest.Mock).mockResolvedValue({
|
|
1158
|
+
id: "callbk_00000000-0000-7000-8000-00000000f001",
|
|
1159
|
+
});
|
|
1160
|
+
(prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
|
|
1161
|
+
|
|
1162
|
+
const fastify = require("fastify")({ logger: false }) as FastifyInstance;
|
|
1163
|
+
await fastify.register(leadCallbackRoute);
|
|
1164
|
+
await fastify.ready();
|
|
1165
|
+
try {
|
|
1166
|
+
const res = await postCallback(fastify);
|
|
1167
|
+
expect(res.statusCode).toBe(201);
|
|
1168
|
+
await flush();
|
|
1169
|
+
|
|
1170
|
+
// Exactly one email, and it is the one carrying the confirm link.
|
|
1171
|
+
expect(email.sendRequestConfirmEmail).toHaveBeenCalledTimes(1);
|
|
1172
|
+
expect(email.sendLeadNotification).not.toHaveBeenCalled();
|
|
1173
|
+
} finally {
|
|
1174
|
+
await fastify.close();
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it("on the FILE-FALLBACK path the generic notification goes and the confirm email does not", async () => {
|
|
1179
|
+
jest.clearAllMocks();
|
|
1180
|
+
stateSentinelWorkspaceExists();
|
|
1181
|
+
enableConfirm();
|
|
1182
|
+
const email = require("@working-theory/email");
|
|
1183
|
+
(prisma.callback.create as jest.Mock).mockRejectedValue(
|
|
1184
|
+
new Error("connection reset"),
|
|
1185
|
+
);
|
|
1186
|
+
|
|
1187
|
+
const fastify = require("fastify")({ logger: false }) as FastifyInstance;
|
|
1188
|
+
await fastify.register(leadCallbackRoute);
|
|
1189
|
+
await fastify.ready();
|
|
1190
|
+
try {
|
|
1191
|
+
const res = await postCallback(fastify);
|
|
1192
|
+
expect(res.statusCode).toBe(201);
|
|
1193
|
+
await flush();
|
|
1194
|
+
|
|
1195
|
+
// No row exists to carry a credential, so the confirm send must not run
|
|
1196
|
+
// — and the notification it would otherwise supersede must.
|
|
1197
|
+
expect(email.sendRequestConfirmEmail).not.toHaveBeenCalled();
|
|
1198
|
+
expect(email.sendLeadNotification).toHaveBeenCalledTimes(1);
|
|
1199
|
+
} finally {
|
|
1200
|
+
await fastify.close();
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
// ── #6335 — the fixture's own guard ───────────────────────────────────────
|
|
1206
|
+
//
|
|
1207
|
+
// Every other case in this file proves a ROUTE behaves. This one proves the
|
|
1208
|
+
// FIXTURE does: that the shared prisma mock ships `workspace.findUnique`
|
|
1209
|
+
// with no implementation at all, so a suite gets "no such workspace" until
|
|
1210
|
+
// it says otherwise. Re-adding a permissive default — the #6203 shape this
|
|
1211
|
+
// issue removed, or any catch-all under another name — fails here, and
|
|
1212
|
+
// fails for that reason rather than as a side effect three suites away.
|
|
1213
|
+
//
|
|
1214
|
+
// Read from a FRESH module instance so the assertion is about what the file
|
|
1215
|
+
// DECLARES, not about whatever the suites above have programmed onto the
|
|
1216
|
+
// double they share.
|
|
1217
|
+
describe("#6335 — the shared prisma mock declares no workspace default", () => {
|
|
1218
|
+
it("workspace.findUnique ships with no implementation, and answers nothing", async () => {
|
|
1219
|
+
await jest.isolateModulesAsync(async () => {
|
|
1220
|
+
const fresh = (
|
|
1221
|
+
require("../../../lib/__mocks__/prisma.js") as {
|
|
1222
|
+
prisma: { workspace: { findUnique: jest.Mock } };
|
|
1223
|
+
}
|
|
1224
|
+
).prisma;
|
|
1225
|
+
|
|
1226
|
+
expect(fresh.workspace.findUnique.getMockImplementation()).toBeUndefined();
|
|
1227
|
+
// A bare `jest.fn()` returns `undefined` rather than a promise, and
|
|
1228
|
+
// `await undefined` is `undefined` — which every resolver in
|
|
1229
|
+
// `lib/site.ts` reads as "no such workspace" and rejects. That is the
|
|
1230
|
+
// fail-closed default this fixture is supposed to have.
|
|
1231
|
+
const answer: unknown = fresh.workspace.findUnique({
|
|
1232
|
+
where: { id: "wksp_00000000-0000-7000-8000-0000000000ff" },
|
|
1233
|
+
});
|
|
1234
|
+
expect(answer).toBeUndefined();
|
|
1235
|
+
await expect(Promise.resolve(answer)).resolves.toBeUndefined();
|
|
1236
|
+
});
|
|
1237
|
+
});
|
|
1238
|
+
});
|