create-ailk 0.5.0 → 0.6.1
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/cli.js +54 -5
- package/dist/copy.d.ts +14 -0
- package/dist/copy.js +38 -1
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -1
- package/dist/lib/extract-module-bundle.js +64 -5
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/lib/module-license-gate.d.ts +6 -1
- package/dist/lib/module-license-gate.js +6 -1
- package/dist/lib/pro-callout.js +1 -1
- package/dist/module-architecture.d.ts +1 -0
- package/dist/module-architecture.js +253 -127
- package/dist/modules/commerce/0.6.0.tar.gz +0 -0
- package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
- package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
- package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
- package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
- package/dist/modules/workspace/0.6.0.tar.gz +0 -0
- package/dist/programmatic.js +14 -10
- 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/DESIGN.md +148 -0
- 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/DESIGN.md +148 -0
- 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/DESIGN.md +148 -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/saas-product/DESIGN.md +148 -0
- package/site-templates/simple-service/DESIGN.md +148 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.env.example +72 -7
- package/templates/CLAUDE.md +2 -3
- package/templates/DESIGN.md +155 -0
- package/templates/README.md +42 -8
- 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/PRODUCT.md +86 -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__/site-theme.test.ts +135 -20
- 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/site-brand.tsx +4 -8
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +187 -0
- package/templates/apps/web/middleware.ts +1 -1
- 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 +93 -0
- package/templates/content/_site.mdx +18 -3
- 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 +149 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/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 +3 -2
- 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/public/logo-dark.svg +0 -21
- package/templates/apps/web/public/logo-light.svg +0 -21
- /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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file dunning-policy.ts
|
|
3
|
+
* @description The per-tenant dunning grace policy (#6034 §§ D4, D5) — one
|
|
4
|
+
* function that resolves how many days a `past_due`/`unpaid` subscription
|
|
5
|
+
* keeps access before a subsequent `canceled` revokes it, plus the two
|
|
6
|
+
* read-side predicates that decide whether a subscriber has lost access or
|
|
7
|
+
* merely needs to act.
|
|
8
|
+
*
|
|
9
|
+
* NOT marked `oss: false` (§ D1) — `routes/webhooks/stripe.ts`, this file's
|
|
10
|
+
* only caller, deliberately left `commerce.ownedFiles`
|
|
11
|
+
* (`packages/create-ailk/src/module-architecture.ts:1818-1846`) so a keyless
|
|
12
|
+
* scaffold still carries and registers it; gating happens at CALL time on the
|
|
13
|
+
* commerce claim already settled at `stripe.ts:174` (NFR-3), not at file
|
|
14
|
+
* presence.
|
|
15
|
+
*
|
|
16
|
+
* The policy blob lives in `SiteSetting` (`database/config/schema.prisma:15`)
|
|
17
|
+
* — a model with NO reader anywhere in the tree before this file (§ 1.7) and
|
|
18
|
+
* a globally-`@unique` `key` column with no `siteId` column of its own. Per-
|
|
19
|
+
* tenant scoping therefore lives in the key STRING:
|
|
20
|
+
* `commerce.dunning_policy:<siteId>` — never an unsuffixed fallback, which
|
|
21
|
+
* would let one site's policy silently govern another's money.
|
|
22
|
+
*
|
|
23
|
+
* The read never throws (AC-3) — see the resolution table on
|
|
24
|
+
* `dunningPolicyFor` below, which mirrors `listing-config.ts`'s
|
|
25
|
+
* absent/malformed → documented-default contract for the same reason: an
|
|
26
|
+
* anonymous webhook delivery must not 500 because an operator (or nothing at
|
|
27
|
+
* all) wrote a bad policy row.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { logger } from "@working-theory/observability";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
|
|
33
|
+
import { isModuleEnabled } from "../config/modules.js";
|
|
34
|
+
import { prisma } from "../lib/prisma.js";
|
|
35
|
+
import { resolveWorkspaceIdForAlias } from "../lib/site.js";
|
|
36
|
+
|
|
37
|
+
/** The lifecycle values this record persists. A narrower set than
|
|
38
|
+
* `SubscriptionLifecycleStatus` (`@working-theory/stripe-client`): `trialing`
|
|
39
|
+
* never persists on its own — an arriving `active` OR `trialing` status is
|
|
40
|
+
* always rewritten to a recorded `status: "active"` (#6034 § D7's clean-
|
|
41
|
+
* recovery contract), so the record's own vocabulary only ever needs three
|
|
42
|
+
* members. */
|
|
43
|
+
export type DunningRecordedStatus = "past_due" | "active" | "canceled";
|
|
44
|
+
|
|
45
|
+
/** One subscription's recorded dunning state (#6034 § D2). */
|
|
46
|
+
export interface DunningEntry {
|
|
47
|
+
status: DunningRecordedStatus;
|
|
48
|
+
accessState: "granted" | "revoked";
|
|
49
|
+
/** ISO-8601, or `null` before the subscription has ever failed. Set once,
|
|
50
|
+
* on first entry into a failing state, and never moved while it stays
|
|
51
|
+
* failing (§ D5) — re-arming on every retry would slide the grace window
|
|
52
|
+
* forward with every Stripe attempt and it would never expire. */
|
|
53
|
+
dunningArmedAt: string | null;
|
|
54
|
+
/** ISO-8601, or `null` while nothing has scheduled a revocation. Present
|
|
55
|
+
* (possibly in the future) the instant a `canceled` event is recorded —
|
|
56
|
+
* the read-side lazily evaluates it rather than any scheduler waking up to
|
|
57
|
+
* flip the row (§ D5 — NFR-7 forbids one). */
|
|
58
|
+
accessRevokesAt: string | null;
|
|
59
|
+
/** Stripe `event.created`, seconds — the ordering watermark § D6's
|
|
60
|
+
* admission rule compares incoming events against. */
|
|
61
|
+
lastEventAt: number;
|
|
62
|
+
/** The durable twin of the in-process `hasSeen` map (§ D6 rule 1) — a
|
|
63
|
+
* replay of the SAME event id is a no-op even after a process restart. */
|
|
64
|
+
lastEventId: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The whole `UserData` blob for the reserved
|
|
68
|
+
* `commerce.subscription_dunning` key — one row per subscriber, keyed by
|
|
69
|
+
* Stripe subscription id because one customer can hold more than one
|
|
70
|
+
* subscription (§ D2 — the map is what stops a second subscription's
|
|
71
|
+
* `active` clobbering the first's `past_due`). */
|
|
72
|
+
export interface DunningRecord {
|
|
73
|
+
v: 1;
|
|
74
|
+
subscriptions: Record<string, DunningEntry>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The reserved `UserData` key this record lives at (§ D12 — refused on
|
|
78
|
+
* `GET`/`PUT`/`DELETE /v1/user/data/:key`; the subject of the access
|
|
79
|
+
* decision may not read, write or erase it). */
|
|
80
|
+
export const DUNNING_USER_DATA_KEY = "commerce.subscription_dunning";
|
|
81
|
+
|
|
82
|
+
/** The map is capped so a per-event read-and-rewrite of the whole blob
|
|
83
|
+
* doesn't grow with a customer's entire subscription history (§ D2, § 9
|
|
84
|
+
* SEC-08). Eviction is oldest-first by `lastEventAt`, skipping any entry
|
|
85
|
+
* that is not yet terminal-and-settled (`status: "active"` with
|
|
86
|
+
* `accessState: "granted"`) — an entry still being dunned is never evicted
|
|
87
|
+
* out from under an in-flight decision. */
|
|
88
|
+
export const DUNNING_MAX_SUBSCRIPTIONS = 50;
|
|
89
|
+
|
|
90
|
+
export interface DunningPolicy {
|
|
91
|
+
graceDays: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 14 days — chosen because Stripe's Smart Retries schedule runs up to
|
|
95
|
+
* roughly three weeks and the grace clock starts at the FIRST failure, not
|
|
96
|
+
* at cancellation (§ D5). A shorter default would have the platform cut a
|
|
97
|
+
* buyer off while Stripe is still actively recovering the payment. */
|
|
98
|
+
export const DUNNING_POLICY_DEFAULTS: DunningPolicy = { graceDays: 14 };
|
|
99
|
+
|
|
100
|
+
/** A tenant may not configure a grace window longer than this. Clamped, not
|
|
101
|
+
* rejected — an operator's out-of-range number is a mistake to correct, not
|
|
102
|
+
* grounds to fall back to a platform default that may be MORE permissive
|
|
103
|
+
* than what they wrote (`listing-config.ts:55-62`'s disposition). */
|
|
104
|
+
export const DUNNING_GRACE_DAYS_MAX = 90;
|
|
105
|
+
|
|
106
|
+
/** Validated shape of the `SiteSetting.value` blob. Accepts any object with
|
|
107
|
+
* unknown extra keys (`.passthrough()`) so the "is this a JSON object at
|
|
108
|
+
* all" check is separated from the fine-grained `graceDays` validation below
|
|
109
|
+
* — zod v4's own `z.number()` already refuses `NaN`/`Infinity` outright,
|
|
110
|
+
* which would collapse the resolution table's distinct `malformed` and
|
|
111
|
+
* `non_finite` outcomes into one, so `graceDays` is read as `unknown` here
|
|
112
|
+
* and narrowed by hand. Deliberately NOT promoted to
|
|
113
|
+
* `@working-theory/validation` (§ D4) — one consumer, no cross-package
|
|
114
|
+
* reader; promoting it now would widen the published surface for nothing. */
|
|
115
|
+
const DunningPolicyBlobSchema = z.object({}).passthrough();
|
|
116
|
+
|
|
117
|
+
/** Logged once per process, the first time a `multi-tenant` deployment reads
|
|
118
|
+
* the policy (§ D4) — an operator should learn that one grace window governs
|
|
119
|
+
* every seller from a log line, not from a wrongful revocation. Process
|
|
120
|
+
* restart re-arms it, mirroring `enrichment-shim.ts`'s pro-sentinel flag. */
|
|
121
|
+
let deploymentWidePolicyWarned = false;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Resolve `siteId`'s dunning grace policy. Never throws (AC-3) — every
|
|
125
|
+
* branch below resolves to a value, logged at the level the § D4 resolution
|
|
126
|
+
* table specifies:
|
|
127
|
+
*
|
|
128
|
+
* no row → default, info "no_policy_row"
|
|
129
|
+
* not a JSON object → default, error "malformed"
|
|
130
|
+
* `graceDays` absent / wrong type → default, error "malformed"
|
|
131
|
+
* `graceDays` non-finite (e.g. `1e999`) → default, error "non_finite"
|
|
132
|
+
* `graceDays` negative and finite → clamp 0, error "clamped_low"
|
|
133
|
+
* `graceDays` finite and > the ceiling → clamp max, error "clamped_high"
|
|
134
|
+
* `graceDays` non-integer (e.g. `7.5`) → floored, warn "floored"
|
|
135
|
+
* the query itself throws → default, error "read_failed"
|
|
136
|
+
* a valid integer in `[0, DUNNING_GRACE_DAYS_MAX]` → that value, no log
|
|
137
|
+
*
|
|
138
|
+
* `siteId` is a PARAMETER, never read via `currentSiteId()` inside this
|
|
139
|
+
* function (§ D4) — a later per-seller resolution is then a caller change
|
|
140
|
+
* only, no signature churn.
|
|
141
|
+
*/
|
|
142
|
+
export async function dunningPolicyFor(siteId: string): Promise<DunningPolicy> {
|
|
143
|
+
// #6374 D3 — the key is built from the UNRESOLVED `siteId` parameter, and
|
|
144
|
+
// that is deliberate, not an oversight. `site_settings` never had a
|
|
145
|
+
// `site_id` column, so the site alias was embedded in the key STRING
|
|
146
|
+
// (`commerce.dunning_policy:default`); the migration's backfill leaves
|
|
147
|
+
// every `key` verbatim. Rebuilding it from the RESOLVED workspace id would
|
|
148
|
+
// match no row and hand every seller the built-in default policy —
|
|
149
|
+
// silently, which is the failure mode this whole function's log table
|
|
150
|
+
// exists to make visible.
|
|
151
|
+
const key = `commerce.dunning_policy:${siteId}`;
|
|
152
|
+
|
|
153
|
+
if (isModuleEnabled("multi-tenant") && !deploymentWidePolicyWarned) {
|
|
154
|
+
deploymentWidePolicyWarned = true;
|
|
155
|
+
logger.warn(
|
|
156
|
+
{ key, outcome: "deployment_wide_policy" },
|
|
157
|
+
"[dunning-policy] this deployment's grace policy is shared by every seller — the multi-tenant module is enabled but the policy key carries no per-seller resolution yet",
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// #6374 D3 — `site_settings` is now keyed by (workspace_id, key): `key`
|
|
162
|
+
// alone is no longer unique, because two sellers may each hold a
|
|
163
|
+
// `feature_flags` row. The WORKSPACE half is resolved from the alias here;
|
|
164
|
+
// the KEY half keeps its embedded alias (see the note above). An alias
|
|
165
|
+
// that resolves to nothing takes the same branch a missing row already
|
|
166
|
+
// takes — the built-in default policy — rather than throwing on a
|
|
167
|
+
// payment-webhook path.
|
|
168
|
+
const workspaceId = await resolveWorkspaceIdForAlias(siteId);
|
|
169
|
+
if (workspaceId === null) {
|
|
170
|
+
logger.info(
|
|
171
|
+
{ siteId, key, outcome: "no_policy_row" },
|
|
172
|
+
"[dunning-policy] site alias resolves to no workspace — using the platform default",
|
|
173
|
+
);
|
|
174
|
+
return DUNNING_POLICY_DEFAULTS;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let row: { value: unknown } | null;
|
|
178
|
+
try {
|
|
179
|
+
row = await prisma.siteSetting.findUnique({
|
|
180
|
+
where: { workspaceId_key: { workspaceId, key } },
|
|
181
|
+
select: { value: true },
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
logger.error(
|
|
185
|
+
{
|
|
186
|
+
siteId,
|
|
187
|
+
key,
|
|
188
|
+
outcome: "read_failed",
|
|
189
|
+
errName: err instanceof Error ? err.name : "UnknownError",
|
|
190
|
+
},
|
|
191
|
+
"[dunning-policy] policy read failed — using the platform default",
|
|
192
|
+
);
|
|
193
|
+
return DUNNING_POLICY_DEFAULTS;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!row) {
|
|
197
|
+
logger.info(
|
|
198
|
+
{ siteId, key, outcome: "no_policy_row" },
|
|
199
|
+
"[dunning-policy] no policy row — using the platform default",
|
|
200
|
+
);
|
|
201
|
+
return DUNNING_POLICY_DEFAULTS;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const parsed = DunningPolicyBlobSchema.safeParse(row.value);
|
|
205
|
+
if (!parsed.success) {
|
|
206
|
+
logger.error(
|
|
207
|
+
{ siteId, key, outcome: "malformed" },
|
|
208
|
+
"[dunning-policy] stored policy is not a JSON object — using the platform default",
|
|
209
|
+
);
|
|
210
|
+
return DUNNING_POLICY_DEFAULTS;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const graceDaysRaw: unknown = (parsed.data as Record<string, unknown>)[
|
|
214
|
+
"graceDays"
|
|
215
|
+
];
|
|
216
|
+
if (typeof graceDaysRaw !== "number") {
|
|
217
|
+
logger.error(
|
|
218
|
+
{ siteId, key, outcome: "malformed" },
|
|
219
|
+
"[dunning-policy] stored graceDays is missing or not a number — using the platform default",
|
|
220
|
+
);
|
|
221
|
+
return DUNNING_POLICY_DEFAULTS;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// `Number.isFinite` covers both the reachable `1e999 → Infinity` overflow
|
|
225
|
+
// AND `NaN`, which cannot arrive from `jsonb` in the first place (there is
|
|
226
|
+
// no `NaN` literal in JSON) — one guard, both cases (§ 9 SEC-10).
|
|
227
|
+
if (!Number.isFinite(graceDaysRaw)) {
|
|
228
|
+
logger.error(
|
|
229
|
+
{ siteId, key, outcome: "non_finite" },
|
|
230
|
+
"[dunning-policy] stored graceDays is not finite — using the platform default",
|
|
231
|
+
);
|
|
232
|
+
return DUNNING_POLICY_DEFAULTS;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (graceDaysRaw < 0) {
|
|
236
|
+
logger.error(
|
|
237
|
+
{ siteId, key, outcome: "clamped_low", stored: graceDaysRaw },
|
|
238
|
+
"[dunning-policy] stored graceDays is negative — clamped to 0",
|
|
239
|
+
);
|
|
240
|
+
return { graceDays: 0 };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (graceDaysRaw > DUNNING_GRACE_DAYS_MAX) {
|
|
244
|
+
logger.error(
|
|
245
|
+
{ siteId, key, outcome: "clamped_high", stored: graceDaysRaw },
|
|
246
|
+
"[dunning-policy] stored graceDays exceeds the ceiling — clamped",
|
|
247
|
+
);
|
|
248
|
+
return { graceDays: DUNNING_GRACE_DAYS_MAX };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!Number.isInteger(graceDaysRaw)) {
|
|
252
|
+
const floored = Math.floor(graceDaysRaw);
|
|
253
|
+
logger.warn(
|
|
254
|
+
{ siteId, key, outcome: "floored", stored: graceDaysRaw },
|
|
255
|
+
"[dunning-policy] stored graceDays is not an integer — floored",
|
|
256
|
+
);
|
|
257
|
+
return { graceDays: floored };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { graceDays: graceDaysRaw };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Access is gone for this subscription: either already stamped `revoked`, or
|
|
265
|
+
* the lazily-evaluated `accessRevokesAt` instant has passed. No scheduler
|
|
266
|
+
* flips this — every reader evaluates it fresh (§ D5, NFR-7).
|
|
267
|
+
*/
|
|
268
|
+
export function isSubscriberRevoked(
|
|
269
|
+
entry: DunningEntry,
|
|
270
|
+
now: number = Date.now(),
|
|
271
|
+
): boolean {
|
|
272
|
+
return (
|
|
273
|
+
entry.accessState === "revoked" ||
|
|
274
|
+
(entry.accessRevokesAt !== null && now >= Date.parse(entry.accessRevokesAt))
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The buyer must act — #6038's banner predicate (§ D5), exported so #6038
|
|
280
|
+
* CALLS it rather than re-deriving it. Deliberately WIDER than
|
|
281
|
+
* `status === "past_due"`: a subscription cancelled with its grace window
|
|
282
|
+
* still open carries `status: "canceled"`, `accessState: "granted"` — a
|
|
283
|
+
* buyer about to lose access who has not been told. Both cases are covered
|
|
284
|
+
* here; neither alone is (§ 9 SEC-09).
|
|
285
|
+
*/
|
|
286
|
+
export function isSubscriberActionRequired(
|
|
287
|
+
entry: DunningEntry,
|
|
288
|
+
now: number = Date.now(),
|
|
289
|
+
): boolean {
|
|
290
|
+
return (
|
|
291
|
+
!isSubscriberRevoked(entry, now) &&
|
|
292
|
+
(entry.status === "past_due" || entry.accessRevokesAt !== null)
|
|
293
|
+
);
|
|
294
|
+
}
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
* spec builds NO metering engine (spec D5).
|
|
18
18
|
*/
|
|
19
19
|
import type { Entitlements } from "@working-theory/license";
|
|
20
|
+
import {
|
|
21
|
+
MAGNET_KIND_MIN_RANK,
|
|
22
|
+
planRank,
|
|
23
|
+
type FlowConfig,
|
|
24
|
+
} from "@working-theory/validation";
|
|
20
25
|
|
|
21
26
|
import { prisma } from "../lib/prisma.js";
|
|
22
27
|
|
|
@@ -66,6 +71,56 @@ export async function resolveEntitlements(
|
|
|
66
71
|
};
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
/**
|
|
75
|
+
* #6287 — the org's plan RANK (never the deployment-wide `checkLicense()`),
|
|
76
|
+
* for the magnet routes' server-side plan gate. Composes `resolveEntitlements`
|
|
77
|
+
* above + `planRank` (`@working-theory/validation`, the same table the
|
|
78
|
+
* Admin's own UI-only gate reads) and NEVER throws: any failure (a DB
|
|
79
|
+
* error, or a tier string `planRank` doesn't recognize) collapses to
|
|
80
|
+
* `undefined` — the caller's fail-closed signal for a gated kind (spec D
|
|
81
|
+
* "the plan-rank resolver never throws").
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveOrgPlanRank(
|
|
84
|
+
organizationId: string,
|
|
85
|
+
): Promise<number | undefined> {
|
|
86
|
+
try {
|
|
87
|
+
const entitlements = await resolveEntitlements(organizationId);
|
|
88
|
+
return planRank(entitlements.limits.static.tier);
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* #6305 — the minimum plan rank a GATED-SHAPE config requires, independent
|
|
96
|
+
* of the `kind` label the caller chose. Derived from `MAGNET_KIND_MIN_RANK`
|
|
97
|
+
* (never hand-duplicated) as the lowest non-zero rank in that table: today
|
|
98
|
+
* that is the same Pro-tier rank every gated kind already requires, so a
|
|
99
|
+
* gated-shape config on an ungated kind is refused exactly as a gated-kind
|
|
100
|
+
* refusal would be (#6305 AC-1).
|
|
101
|
+
*/
|
|
102
|
+
export const GATED_FLOW_SHAPE_MIN_RANK = Math.min(
|
|
103
|
+
...Object.values(MAGNET_KIND_MIN_RANK).filter((rank) => rank > 0),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* #6305 — whether a `FlowConfig` carries the shape the flow runtime actually
|
|
108
|
+
* branches on for gated (recommending-mode) behavior, regardless of which
|
|
109
|
+
* `kind` label the row carries. This is the SAME predicate family
|
|
110
|
+
* `deriveFlowKind`-style logic elsewhere in this codebase reads
|
|
111
|
+
* (`mode === "recommending"`, or the presence of `deliverables` / `offers` /
|
|
112
|
+
* `scoring`) — the label and the capability it confers can no longer diverge.
|
|
113
|
+
*/
|
|
114
|
+
export function configHasGatedShape(config: FlowConfig | undefined): boolean {
|
|
115
|
+
if (!config) return false;
|
|
116
|
+
return (
|
|
117
|
+
config.mode === "recommending" ||
|
|
118
|
+
config.deliverables !== undefined ||
|
|
119
|
+
config.offers !== undefined ||
|
|
120
|
+
config.scoring !== undefined
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
69
124
|
/**
|
|
70
125
|
* The org's Stripe billing-customer identity (#3701 — the apps/api
|
|
71
126
|
* predecessor to the org-billing UI, #3697).
|
|
@@ -100,3 +155,28 @@ export async function getOrgBillingIdentity(
|
|
|
100
155
|
|
|
101
156
|
return { billingCustomerId: org?.billingCustomerId ?? null };
|
|
102
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether ANY Organization row carries the given Stripe customer id (#6038
|
|
161
|
+
* D3.2, closes SA-4). Touches only `Organization.billingCustomerId` by
|
|
162
|
+
* explicit value — the same sanctioned-exception shape as
|
|
163
|
+
* `getOrgBillingIdentity` above (by id) and `resolveEntitlements` (by id),
|
|
164
|
+
* never an un-scoped tenant/content query.
|
|
165
|
+
*
|
|
166
|
+
* Exists because no code in this repo stamps `metadata.organizationId` onto
|
|
167
|
+
* a Stripe customer, so every `customer.created` takes the `User` branch
|
|
168
|
+
* (`webhooks/stripe.ts`'s `upsertBillingIdentity`) and a single `cus_…` can
|
|
169
|
+
* end up projected onto BOTH a `User` row and an `Organization` row. A
|
|
170
|
+
* caller resolving "the caller's own portal" from `User.billingCustomerId`
|
|
171
|
+
* alone would then risk opening the ORGANIZATION's billing record instead —
|
|
172
|
+
* this is the collision check that refuses that case.
|
|
173
|
+
*/
|
|
174
|
+
export async function isBillingCustomerAnOrganization(
|
|
175
|
+
billingCustomerId: string,
|
|
176
|
+
): Promise<boolean> {
|
|
177
|
+
const org = await prisma.organization.findFirst({
|
|
178
|
+
where: { billingCustomerId },
|
|
179
|
+
select: { id: true },
|
|
180
|
+
});
|
|
181
|
+
return org !== null;
|
|
182
|
+
}
|
|
@@ -24,6 +24,7 @@ import type { Id } from "@working-theory/ids";
|
|
|
24
24
|
import { prisma } from "../lib/prisma.js";
|
|
25
25
|
|
|
26
26
|
import { resolveOwnerWorkspaces } from "./owner-workspaces.js";
|
|
27
|
+
import { findVerifiedEmail } from "./verified-email.js";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Workspace resolution moved to `owner-workspaces.ts` (#5678) so the leads
|
|
@@ -86,23 +87,21 @@ export async function promoteLeadsForVerifiedUser(params: {
|
|
|
86
87
|
const { userId } = params;
|
|
87
88
|
|
|
88
89
|
try {
|
|
89
|
-
const user = await prisma.user.findUnique({
|
|
90
|
-
where: { id: userId },
|
|
91
|
-
select: { id: true, email: true, emailVerified: true },
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
if (!user) {
|
|
95
|
-
return {
|
|
96
|
-
ok: false,
|
|
97
|
-
error: { kind: "user_not_found", message: "User not found" },
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
90
|
// D2 — the verified-email gate is a service-level invariant, not a
|
|
102
|
-
// caller obligation: it cannot be bypassed by a wrong call-site.
|
|
103
|
-
|
|
91
|
+
// caller obligation: it cannot be bypassed by a wrong call-site. The read
|
|
92
|
+
// and the refusal both live in `services/verified-email.ts` (#5701 D4),
|
|
93
|
+
// shared with the four other email-keyed paths.
|
|
94
|
+
const verified = await findVerifiedEmail(userId);
|
|
95
|
+
if (!verified.ok) {
|
|
96
|
+
if (verified.reason === "user_not_found") {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: { kind: "user_not_found", message: "User not found" },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
104
102
|
return { ok: true, count: 0, promoted: [] };
|
|
105
103
|
}
|
|
104
|
+
const user = verified.value;
|
|
106
105
|
|
|
107
106
|
// D3/D4 — resolve the verifying user's OWN workspaces through the one
|
|
108
107
|
// shared resolution (`owner-workspaces.ts`). Role-agnostic here:
|
|
@@ -10,6 +10,18 @@
|
|
|
10
10
|
*
|
|
11
11
|
* PII boundary: only { siteId, kind, ip, persisted, emailKind } appear in logs.
|
|
12
12
|
* Never log email, phone, name, or payload values.
|
|
13
|
+
*
|
|
14
|
+
* TENANCY (#6203). `input.siteId` is the caller's RESOLVED workspace id —
|
|
15
|
+
* `routes/leads/index.ts` resolves it server-side before calling `routeLead`
|
|
16
|
+
* and never a client-supplied value (see that file's header). `Lead` gets a
|
|
17
|
+
* `workspaceId` stamp of the same value, and since #6374 so do `Subscriber`
|
|
18
|
+
* and `Callback` — both gained a required `workspaceId` column in the fence
|
|
19
|
+
* fan-out, stamped from the same resolved id by the three `create` sites
|
|
20
|
+
* below. (This line previously said those two models had no such column;
|
|
21
|
+
* that stopped being true when the keystone migration landed, and the code
|
|
22
|
+
* under it already contradicted it.) The one READ in this file — the
|
|
23
|
+
* duplicate-subscriber lookup on the P2002 arm — is scoped by it too
|
|
24
|
+
* (#6257).
|
|
13
25
|
*/
|
|
14
26
|
|
|
15
27
|
import { randomUUID } from "node:crypto";
|
|
@@ -23,6 +35,7 @@ import { appendLeadRecord } from "../lib/lead-capture-store.js";
|
|
|
23
35
|
import { prisma } from "../lib/prisma.js";
|
|
24
36
|
|
|
25
37
|
import { enrichLead } from "./enrichment-shim.js";
|
|
38
|
+
import { requestConfirmSupersedesNotification } from "./request-confirm.js";
|
|
26
39
|
|
|
27
40
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
28
41
|
|
|
@@ -103,6 +116,23 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
|
|
|
103
116
|
const row = (await prisma.lead.create({
|
|
104
117
|
data: {
|
|
105
118
|
siteId: input.siteId,
|
|
119
|
+
// #6203/#6254 — the isolation key, equal to `siteId` by
|
|
120
|
+
// construction. `input.siteId` is the RESOLVED workspace id at
|
|
121
|
+
// EVERY `routeLead(kind: "submit")` caller now — the primary route
|
|
122
|
+
// (`routes/leads/index.ts`, never reads it from the body — see that
|
|
123
|
+
// file's header) and the three service-layer callers
|
|
124
|
+
// (`flow-engine.ts`'s `completeSubmission`,
|
|
125
|
+
// `recommender-capture.ts`'s `captureRecommenderChoice`,
|
|
126
|
+
// `project-listings.ts`'s `submitProjectListingDraft`) all resolve
|
|
127
|
+
// the site alias before calling in (#6254 AC-6/AC-10). `workspaceId`
|
|
128
|
+
// is NOT NULL as of
|
|
129
|
+
// database/migrations/20260922120000_tenancy_fence, so this can no
|
|
130
|
+
// longer stamp `null` for an unresolved alias the way it once did
|
|
131
|
+
// (spec D2's old rule) — a caller that somehow still passed one
|
|
132
|
+
// would instead hit the pre-existing `<table>_workspace_id_format`
|
|
133
|
+
// CHECK loudly, via the AC-11 re-raise below, rather than landing a
|
|
134
|
+
// silently-unfenced row.
|
|
135
|
+
workspaceId: input.siteId,
|
|
106
136
|
source: input.source,
|
|
107
137
|
email: input.email,
|
|
108
138
|
payload: input.payload as Prisma.InputJsonValue,
|
|
@@ -117,6 +147,13 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
|
|
|
117
147
|
const row = (await prisma.subscriber.create({
|
|
118
148
|
data: {
|
|
119
149
|
siteId: input.siteId,
|
|
150
|
+
// #6374 — the isolation key, equal to `siteId` by construction,
|
|
151
|
+
// exactly as `lead.create` above stamps it. `input.siteId` is the
|
|
152
|
+
// RESOLVED workspace id at every `routeLead` caller (see the
|
|
153
|
+
// `submit` branch's own note); a caller that somehow passed a raw
|
|
154
|
+
// alias would hit `subscribers_workspace_id_format` loudly rather
|
|
155
|
+
// than landing a silently-unfenced row. Never from a request body.
|
|
156
|
+
workspaceId: input.siteId,
|
|
120
157
|
email: input.email,
|
|
121
158
|
source: input.source,
|
|
122
159
|
},
|
|
@@ -131,8 +168,15 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
|
|
|
131
168
|
"code" in err &&
|
|
132
169
|
(err as { code: string }).code === "P2002"
|
|
133
170
|
) {
|
|
171
|
+
// #6257 — the duplicate lookup is scoped by the same resolved
|
|
172
|
+
// workspace id the `create` above stamps (`input.siteId` is the
|
|
173
|
+
// RESOLVED id at every caller — see the `submit` branch's note), so a
|
|
174
|
+
// P2002 on another tenant's row can never resolve to its id.
|
|
134
175
|
const existing = (await prisma.subscriber.findUnique({
|
|
135
|
-
where: {
|
|
176
|
+
where: {
|
|
177
|
+
siteId_email: { siteId: input.siteId, email: input.email },
|
|
178
|
+
workspaceId: input.siteId,
|
|
179
|
+
},
|
|
136
180
|
select: { id: true },
|
|
137
181
|
})) as DbRow | null;
|
|
138
182
|
const existingId = existing?.id ?? "duplicate-no-id";
|
|
@@ -152,6 +196,10 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
|
|
|
152
196
|
const row = (await prisma.callback.create({
|
|
153
197
|
data: {
|
|
154
198
|
siteId: input.siteId,
|
|
199
|
+
// #6374 — the isolation key, equal to `siteId` by construction; same
|
|
200
|
+
// resolution guarantee and same loud-failure posture as the two
|
|
201
|
+
// branches above. Never from a request body (AC-6).
|
|
202
|
+
workspaceId: input.siteId,
|
|
155
203
|
name: input.name,
|
|
156
204
|
phone: input.phone,
|
|
157
205
|
...(input.preferredTime !== undefined && {
|
|
@@ -164,6 +212,56 @@ async function persistToDb(input: RouteLeadInput): Promise<PersistResult> {
|
|
|
164
212
|
return { ok: true, id: row.id, source: "db", skipEmail: false };
|
|
165
213
|
}
|
|
166
214
|
|
|
215
|
+
// ─── Constraint-violation detection (#6254 AC-11) ──────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* SQLSTATE class 23 — "integrity constraint violation" — covers a NOT NULL
|
|
219
|
+
* (23502), a CHECK (23514), a unique (23505) or a foreign-key (23503)
|
|
220
|
+
* violation. Two of Prisma's own mapped codes correspond to a class-23
|
|
221
|
+
* violation on a `.create()` call: P2002 (unique) and P2011 (null
|
|
222
|
+
* constraint) — both `PrismaClientKnownRequestError`s Prisma classifies
|
|
223
|
+
* from its OWN schema knowledge. The new `<table>_workspace_site_agree`
|
|
224
|
+
* CHECK (database/migrations/20260922120000_tenancy_fence) has no such
|
|
225
|
+
* schema-level representation — Prisma cannot classify a raw CHECK it does
|
|
226
|
+
* not know about, so the raw Postgres SQLSTATE is read defensively from
|
|
227
|
+
* wherever the error actually carries it: `error.meta.code` (some engine/
|
|
228
|
+
* adapter combinations thread it through there) or the error message text
|
|
229
|
+
* (every Postgres client library's message includes the SQLSTATE and/or the
|
|
230
|
+
* words "constraint"/"null value" verbatim).
|
|
231
|
+
*/
|
|
232
|
+
// P2000 (value too long) is deliberately excluded — a data-length validation
|
|
233
|
+
// error, not a SQLSTATE class-23 integrity-constraint violation (security
|
|
234
|
+
// review finding, PR #6360): it doesn't correspond to 23502/23503/23505/
|
|
235
|
+
// 23514, so treating it as one would take this path for a case AC-11 does
|
|
236
|
+
// not target.
|
|
237
|
+
const PRISMA_CLASS_23_CODES = new Set(["P2002", "P2003", "P2004", "P2011"]);
|
|
238
|
+
|
|
239
|
+
function isConstraintViolation(error: unknown): boolean {
|
|
240
|
+
if (error == null || typeof error !== "object") return false;
|
|
241
|
+
|
|
242
|
+
const code = (error as { code?: unknown }).code;
|
|
243
|
+
if (typeof code === "string" && PRISMA_CLASS_23_CODES.has(code)) return true;
|
|
244
|
+
|
|
245
|
+
const metaCode = (error as { meta?: { code?: unknown } }).meta?.code;
|
|
246
|
+
if (typeof metaCode === "string" && /^23\d{3}$/.test(metaCode)) return true;
|
|
247
|
+
|
|
248
|
+
const message = (error as { message?: unknown }).message;
|
|
249
|
+
if (typeof message === "string") {
|
|
250
|
+
// Labeled SQLSTATE only — "code: '23514'", "(SQLSTATE 23502)", etc. A
|
|
251
|
+
// bare `\b23\d{3}\b` anywhere in the message (security review finding,
|
|
252
|
+
// PR #6360) would also match a coincidental 5-digit number in a genuine
|
|
253
|
+
// outage's own text (a port, a byte count, a timeout in ms), stealing
|
|
254
|
+
// that case away from the file fallback it should still take.
|
|
255
|
+
if (/\b(?:sqlstate|code)[:\s]*['"]?23\d{3}['"]?/i.test(message)) return true;
|
|
256
|
+
if (/violates (check|not-null|unique|foreign key) constraint/i.test(message)) {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
if (/null value in column/i.test(message)) return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
167
265
|
// ─── Email ────────────────────────────────────────────────────────────────────
|
|
168
266
|
|
|
169
267
|
type EmailResult =
|
|
@@ -174,7 +272,38 @@ type EmailResult =
|
|
|
174
272
|
async function sendEmail(
|
|
175
273
|
input: RouteLeadInput,
|
|
176
274
|
id: string,
|
|
275
|
+
persisted: "db" | "file",
|
|
177
276
|
): Promise<EmailResult> {
|
|
277
|
+
// Request-and-confirm SUPERSEDES this notification for a callback (#5970
|
|
278
|
+
// D6). AC-1 requires the tenant to receive exactly one email, and the
|
|
279
|
+
// confirm message carries strictly more than this one does — the same
|
|
280
|
+
// request details, plus the link that confirms a time and a price. Sending
|
|
281
|
+
// both would be two; sending this one and not that one would lose the
|
|
282
|
+
// feature. `submit` and `subscribe` are untouched.
|
|
283
|
+
//
|
|
284
|
+
// The predicate lives in request-confirm.ts and is read by both sides, so
|
|
285
|
+
// the two cannot both fire. It does NOT by itself rule out zero — see that
|
|
286
|
+
// function's header for what the coupling does and does not guarantee, and
|
|
287
|
+
// for the `persisted` term below, which is what closed the one zero case
|
|
288
|
+
// that was reachable in production. With the feature unconfigured — the
|
|
289
|
+
// default, and what every existing leads suite runs under — this is false
|
|
290
|
+
// and every byte below is reached as before.
|
|
291
|
+
// `persisted === "db"` is load-bearing, not decoration (#5970 security
|
|
292
|
+
// review round 3, M1). The confirm message can only supersede this one if it
|
|
293
|
+
// is actually going to be sent, and it can only be sent against a ROW: the
|
|
294
|
+
// credential is minted by an `updateMany` on `callbacks`. On the FILE
|
|
295
|
+
// FALLBACK path — a DB outage, an unset DATABASE_URL, an exhausted pool —
|
|
296
|
+
// there is no row, so suppressing here would have produced ZERO operator
|
|
297
|
+
// emails for every callback captured during the outage, on the one surface
|
|
298
|
+
// whose whole purpose is that an outage must not lose a lead.
|
|
299
|
+
if (
|
|
300
|
+
input.kind === "callback" &&
|
|
301
|
+
persisted === "db" &&
|
|
302
|
+
requestConfirmSupersedesNotification()
|
|
303
|
+
) {
|
|
304
|
+
return { skipped: true, reason: "request_confirm" };
|
|
305
|
+
}
|
|
306
|
+
|
|
178
307
|
const to = process.env.AILK_LEAD_NOTIFICATION_TO;
|
|
179
308
|
const from = process.env.AILK_LEAD_NOTIFICATION_FROM;
|
|
180
309
|
|
|
@@ -265,6 +394,36 @@ export async function routeLead(
|
|
|
265
394
|
skipEmail = dbResult.skipEmail;
|
|
266
395
|
skipReason = dbResult.skipReason;
|
|
267
396
|
} catch (dbErr) {
|
|
397
|
+
// #6254 AC-11 (spec D6, SEC-02) — a constraint violation is OUR OWN
|
|
398
|
+
// derivation producing a row Postgres itself refuses (an unresolved
|
|
399
|
+
// workspaceId hitting NOT NULL, or a site<->workspace disagreement
|
|
400
|
+
// hitting the new equality CHECK) — a bug to surface, never an outage
|
|
401
|
+
// to degrade through. Falling back here would return a silent 201 while
|
|
402
|
+
// the Lead never lands in Postgres, invisible to the Inbox forever —
|
|
403
|
+
// the exact silent data loss AC-7 exists to close, merely relocated
|
|
404
|
+
// onto this never-gate surface.
|
|
405
|
+
//
|
|
406
|
+
// A DELIBERATE error response, not a bare re-raise (spec § 7's
|
|
407
|
+
// api-versioning waiver is conditioned on exactly this: "a documented
|
|
408
|
+
// error body, never a bare re-raise surfacing as an unhandled throw").
|
|
409
|
+
// `persist_failed` already maps to a 503 with a documented body
|
|
410
|
+
// (`routes/leads/index.ts`) — the SAME shape a genuine DB-outage
|
|
411
|
+
// failure (both DB and file fail) already returns, so this adds no new
|
|
412
|
+
// response shape, only a new path that reaches the existing one
|
|
413
|
+
// WITHOUT first trying — and losing time to — a file write for an
|
|
414
|
+
// error the file fallback can never fix.
|
|
415
|
+
//
|
|
416
|
+
// A GENUINE infrastructure failure (an unreachable DB, an exhausted
|
|
417
|
+
// pool, an unset DATABASE_URL) is NOT a constraint violation and still
|
|
418
|
+
// takes the file fallback below, unchanged.
|
|
419
|
+
if (isConstraintViolation(dbErr)) {
|
|
420
|
+
const dbMsg = dbErr instanceof Error ? dbErr.message : String(dbErr);
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
error: { kind: "persist_failed", message: `db: ${dbMsg}` },
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
268
427
|
// 5. File fallback
|
|
269
428
|
// Include a UUID suffix so concurrent fallbacks produce unique IDs even
|
|
270
429
|
// within the same millisecond. Format still matches /^[a-z0-9_-]{8,64}$/i.
|
|
@@ -331,7 +490,7 @@ export async function routeLead(
|
|
|
331
490
|
if (skipEmail) {
|
|
332
491
|
emailResult = { skipped: true, reason: skipReason ?? "skipped" };
|
|
333
492
|
} else {
|
|
334
|
-
emailResult = await sendEmail(input, id!);
|
|
493
|
+
emailResult = await sendEmail(input, id!, persisted!);
|
|
335
494
|
}
|
|
336
495
|
|
|
337
496
|
return { ok: true, id: id!, persisted, email: emailResult };
|