create-ailk 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -8
- package/component-catalog.json +12248 -0
- package/component-catalog.md +193 -68
- package/dist/cli.js +29 -1
- package/dist/derive-architecture.js +5 -2
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/extract-module-bundle.d.ts +50 -2
- package/dist/lib/extract-module-bundle.js +223 -15
- package/dist/lib/fetch-guard.d.ts +28 -0
- package/dist/lib/fetch-guard.js +101 -0
- package/dist/lib/fetch-module.js +4 -0
- package/dist/lib/fetch-template.js +4 -0
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/module-architecture.d.ts +60 -8
- package/dist/module-architecture.js +587 -242
- package/dist/parse-args.js +6 -2
- package/dist/programmatic.d.ts +10 -0
- package/dist/programmatic.js +134 -19
- package/dist/site-chrome.d.ts +80 -0
- package/dist/site-chrome.js +135 -0
- package/dist/surfaces.d.ts +21 -6
- package/dist/surfaces.js +209 -36
- package/dist/template-manifest.d.ts +88 -3
- package/dist/template-manifest.js +281 -29
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +2 -0
- package/package.json +10 -2
- package/site-templates/_base/ailk-template.json +6 -0
- package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
- package/site-templates/marketing-site/ailk-template.json +11 -0
- package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
- package/site-templates/marketing-site/provenance.json +4 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
- package/site-templates/marketing-site-free/ailk-template.json +2 -1
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
- package/site-templates/minimalism-site/ailk-template.json +22 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
- package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
- package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
- package/site-templates/minimalism-site/provenance.json +4 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
- package/site-templates/saas-product/ailk-template.json +16 -0
- package/site-templates/saas-product/provenance.json +10 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.claude/skills/README.md +16 -25
- package/templates/.env.example +102 -12
- package/templates/.github/workflows/ci.yml +48 -0
- package/templates/README.md +3 -1
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
- package/templates/apps/api/.env.example +13 -0
- package/templates/apps/api/CLAUDE.md +29 -2
- package/templates/apps/api/eslint.config.mjs +69 -0
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +7 -2
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
- package/templates/apps/api/src/__tests__/server.test.ts +471 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
- package/templates/apps/api/src/config/index.ts +62 -0
- package/templates/apps/api/src/config/modules.ts +7 -3
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
- package/templates/apps/api/src/lib/content-adapter.ts +57 -31
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
- package/templates/apps/api/src/lib/error-serializer.ts +23 -3
- package/templates/apps/api/src/lib/outbound-http.ts +7 -2
- package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
- package/templates/apps/api/src/lib/site.ts +208 -9
- package/templates/apps/api/src/lib/stripe.ts +66 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
- package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
- package/templates/apps/api/src/openapi/spec.ts +543 -2
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
- package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
- package/templates/apps/api/src/routes/auth/user.ts +61 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
- package/templates/apps/api/src/routes/checkout/index.ts +34 -0
- package/templates/apps/api/src/routes/checkout/session.ts +139 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
- package/templates/apps/api/src/routes/content/get-page.ts +94 -12
- package/templates/apps/api/src/routes/content/index.ts +13 -73
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
- package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
- package/templates/apps/api/src/routes/content/search.ts +129 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/leads/list.ts +104 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
- package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
- package/templates/apps/api/src/routes/requests/index.ts +54 -0
- package/templates/apps/api/src/routes/webhooks/README.md +265 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
- package/templates/apps/api/src/server.ts +521 -55
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
- package/templates/apps/api/src/services/abandon-email.ts +8 -2
- package/templates/apps/api/src/services/aeo-score.ts +49 -12
- package/templates/apps/api/src/services/audit-log.ts +668 -0
- package/templates/apps/api/src/services/consent.ts +18 -0
- package/templates/apps/api/src/services/default-tenant.ts +300 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
- package/templates/apps/api/src/services/dunning-policy.ts +294 -0
- package/templates/apps/api/src/services/entitlements.ts +80 -0
- package/templates/apps/api/src/services/lead-promotion.ts +160 -0
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +284 -0
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
- package/templates/apps/api/src/services/project-listings.ts +201 -6
- package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
- package/templates/apps/api/src/services/request-confirm.ts +646 -0
- package/templates/apps/api/src/services/revalidation.ts +173 -0
- package/templates/apps/api/src/services/verified-email.ts +71 -0
- package/templates/apps/api/src/vercel-handler.ts +75 -35
- package/templates/apps/mcp/.env.example +17 -0
- package/templates/apps/mcp/CLAUDE.md +58 -25
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
- package/templates/apps/mcp/__tests__/index.test.ts +39 -0
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
- package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
- package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
- package/templates/apps/mcp/__tests__/server.test.ts +67 -14
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
- package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/eslint.config.mjs +69 -0
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +8 -2
- package/templates/apps/mcp/src/abuse/index.ts +159 -24
- package/templates/apps/mcp/src/config/modules.ts +10 -5
- package/templates/apps/mcp/src/http-transport.ts +102 -29
- package/templates/apps/mcp/src/index.ts +73 -11
- package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
- package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
- package/templates/apps/mcp/src/lib/prisma.ts +39 -0
- package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
- package/templates/apps/mcp/src/server.ts +61 -37
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
- package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
- package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
- package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
- package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
- package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
- package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
- package/templates/apps/mcp/src/tools/index.ts +9 -7
- package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
- package/templates/apps/mcp/src/tools/purchase.ts +128 -45
- package/templates/apps/mcp/src/tools/read_content.ts +20 -6
- package/templates/apps/mcp/src/tools/search_content.ts +11 -4
- package/templates/apps/web/.env.example +74 -8
- package/templates/apps/web/CLAUDE.md +46 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +308 -65
- package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
- package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
- package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
- package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
- package/templates/apps/web/app/api/revalidate/route.ts +194 -0
- package/templates/apps/web/auth.ts +407 -8
- package/templates/apps/web/components/google-analytics.tsx +18 -1
- package/templates/apps/web/eslint.config.mjs +69 -0
- package/templates/apps/web/jest.config.cjs +15 -19
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
- package/templates/apps/web/lib/auth-email.ts +318 -0
- package/templates/apps/web/lib/auth-env.ts +85 -0
- package/templates/apps/web/lib/auth-routes.ts +55 -0
- package/templates/apps/web/lib/meeting-slugs.ts +53 -0
- package/templates/apps/web/lib/nav-config.ts +27 -7
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/site-chrome.ts +29 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
- package/templates/apps/web/messages/en.json +216 -2
- package/templates/apps/web/next.config.mjs +20 -0
- package/templates/apps/web/package.json +8 -3
- package/templates/apps/web/sentry.client.config.ts +131 -0
- package/templates/apps/web/sentry.edge.config.ts +72 -0
- package/templates/apps/web/sentry.server.config.ts +72 -0
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
- package/templates/apps/web/src/lib/routes.ts +13 -18
- package/templates/brand_assets/README.md +24 -0
- package/templates/content/en/AboutPage/about.mdx +160 -15
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ContactPage/contact.mdx +90 -13
- package/templates/content/en/FAQPage/faq.mdx +62 -22
- package/templates/content/en/HomePage/home.mdx +244 -20
- package/templates/content/en/LandingPage/blog.mdx +83 -0
- package/templates/content/en/LandingPage/features.mdx +103 -0
- package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +149 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/seed.test.ts +1 -1
- package/templates/database/__tests__/tenancy.test.ts +141 -0
- package/templates/database/config/schema.prisma +79 -2
- package/templates/database/content/schema.prisma +87 -11
- package/templates/database/eslint.config.mjs +69 -0
- package/templates/database/inbox/schema.prisma +452 -9
- package/templates/database/jest.config.cjs +5 -3
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
- package/templates/database/package.json +7 -3
- package/templates/database/seed.ts +1 -1
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/templates/package.json +1 -14
- package/versions.json +52 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
- package/templates/apps/api/src/routes/content/create.ts +0 -115
- package/templates/apps/api/src/routes/content/delete.ts +0 -82
- package/templates/apps/api/src/routes/content/update.ts +0 -112
- package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
- package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
- package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
- package/templates/apps/mcp/src/tools/create_page.ts +0 -96
- package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
- package/templates/apps/mcp/src/tools/update_page.ts +0 -92
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
- /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
- /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
- /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file lead-promotion.ts
|
|
3
|
+
* @description Lead → user promotion across the inbox/auth namespace boundary
|
|
4
|
+
* (ADR 0009 D3 service-layer pattern; spec
|
|
5
|
+
* docs/specs/3586-lead-user-promotion-spec.md).
|
|
6
|
+
*
|
|
7
|
+
* Reconciles an anonymous inbox.Lead with an auth.User by stable email, once
|
|
8
|
+
* the reconciling user's own email is verified (D2 — the consent basis for
|
|
9
|
+
* linking anonymous-lead PII to a user) and only within that user's own
|
|
10
|
+
* tenant workspaces (D3 — the tenant-safety invariant). Workspace resolution
|
|
11
|
+
* is delegated to `owner-workspaces.ts` — the one site→workspace resolution in
|
|
12
|
+
* this app, which in turn delegates to the sole cross-namespace tenancy
|
|
13
|
+
* assembler — so this file never issues a raw membership or workspace model
|
|
14
|
+
* query of its own (architecture.yaml's tenant-scope forbidden pattern
|
|
15
|
+
* reserves those to the tenant-context / tenant-db modules).
|
|
16
|
+
*
|
|
17
|
+
* Mirrors lead-routing.ts's shape: discriminated `{ ok } | { ok: false, error }`
|
|
18
|
+
* result, no throw on the expected (no-op) paths, PII-safe (never logs email /
|
|
19
|
+
* payload / name / phone).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Id } from "@working-theory/ids";
|
|
23
|
+
|
|
24
|
+
import { prisma } from "../lib/prisma.js";
|
|
25
|
+
|
|
26
|
+
import { resolveOwnerWorkspaces } from "./owner-workspaces.js";
|
|
27
|
+
import { findVerifiedEmail } from "./verified-email.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Workspace resolution moved to `owner-workspaces.ts` (#5678) so the leads
|
|
31
|
+
* READ path can share it rather than write a second one. The module-
|
|
32
|
+
* conditional slice load, the zero-workspace semantics and the delegation to
|
|
33
|
+
* the tenancy assembler are unchanged — this file simply calls it now.
|
|
34
|
+
*
|
|
35
|
+
* It had to be a separate module rather than an export from here:
|
|
36
|
+
* `paidModuleTemplateExclusions()` drops every file that reaches a paid
|
|
37
|
+
* module's `ownedFiles` through the import graph, this file is inside that
|
|
38
|
+
* closure, and importing it from the leads read would have carried the
|
|
39
|
+
* closure all the way into `server.ts`. See `owner-workspaces.ts`'s own
|
|
40
|
+
* header.
|
|
41
|
+
*
|
|
42
|
+
* Semantics on a build without the multi-tenant module, CORRECTED by #5677
|
|
43
|
+
* and carried across the move. `services/default-tenant.ts` now resolves the
|
|
44
|
+
* seeded default Workspace on every build, so a module-less deployment DOES
|
|
45
|
+
* have a resolvable workspace — the shared resolver's empty result there is a
|
|
46
|
+
* deliberate scope limit, not a statement that there is nothing to resolve.
|
|
47
|
+
*
|
|
48
|
+
* PROMOTION specifically is not wired to that default resolution, and that is
|
|
49
|
+
* #5677's own disposition rather than an oversight of this move: single-tenant
|
|
50
|
+
* leads would start acquiring `promotedUserId`, which is what
|
|
51
|
+
* `/v1/flow-checkouts/me` and `/v1/waitlist-signups/me` join a respondent's own
|
|
52
|
+
* rows through — a respondent's `/account` would begin showing purchases where
|
|
53
|
+
* it shows none today. It also needs the deployment to pin `AILK_SITE_ID` to
|
|
54
|
+
* `DEFAULT_WORKSPACE_ID`, because `siteId` comes from `lib/site.ts`
|
|
55
|
+
* (`AILK_SITE_ID ?? "default"`) while `workspaceId` is the sentinel, and
|
|
56
|
+
* `siteId ∈ workspaceIds` only matches when those agree.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export type PromotedLead = {
|
|
62
|
+
leadId: string;
|
|
63
|
+
siteId: string;
|
|
64
|
+
source: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type PromoteLeadsResult =
|
|
68
|
+
| { ok: true; count: number; promoted: PromotedLead[] }
|
|
69
|
+
| {
|
|
70
|
+
ok: false;
|
|
71
|
+
error: { kind: "user_not_found" | "internal_error"; message: string };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ─── Main export ──────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Promote every un-promoted `inbox.Lead` matching the verifying user's own
|
|
78
|
+
* email, within that user's own tenant workspaces (D1/D3). No-ops
|
|
79
|
+
* (`count: 0`) — never throws — when: the user's email is unverified (D2),
|
|
80
|
+
* the user belongs to no workspace, or no matching un-promoted lead exists.
|
|
81
|
+
* Idempotent: a lead already carrying `promotedUserId` is excluded from the
|
|
82
|
+
* match set, so re-invocation promotes nothing (AC-6).
|
|
83
|
+
*/
|
|
84
|
+
export async function promoteLeadsForVerifiedUser(params: {
|
|
85
|
+
userId: string;
|
|
86
|
+
}): Promise<PromoteLeadsResult> {
|
|
87
|
+
const { userId } = params;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// D2 — the verified-email gate is a service-level invariant, not a
|
|
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
|
+
}
|
|
102
|
+
return { ok: true, count: 0, promoted: [] };
|
|
103
|
+
}
|
|
104
|
+
const user = verified.value;
|
|
105
|
+
|
|
106
|
+
// D3/D4 — resolve the verifying user's OWN workspaces through the one
|
|
107
|
+
// shared resolution (`owner-workspaces.ts`). Role-agnostic here:
|
|
108
|
+
// promotion links a lead to the person whose verified email it carries,
|
|
109
|
+
// in any workspace they belong to, exactly as it did before the move.
|
|
110
|
+
// Without the multi-tenant module the shared resolver yields nothing and
|
|
111
|
+
// the early return below fires — a scope limit since #5677, not an
|
|
112
|
+
// absence of anything to resolve; see the header.
|
|
113
|
+
const workspaces = await resolveOwnerWorkspaces(user.id as Id<"user">);
|
|
114
|
+
if (!workspaces.ok) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: { kind: "internal_error", message: workspaces.error.message },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
const workspaceIds = workspaces.value.map((workspace) => workspace.id);
|
|
121
|
+
|
|
122
|
+
if (workspaceIds.length === 0) {
|
|
123
|
+
return { ok: true, count: 0, promoted: [] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// D3/AC-4 (tenant-safety — load-bearing): the match set is siteId ∈ the
|
|
127
|
+
// user's OWN workspaceIds, so a lead captured under a different
|
|
128
|
+
// organization's workspace is never matched, by construction.
|
|
129
|
+
const candidates = await prisma.lead.findMany({
|
|
130
|
+
where: {
|
|
131
|
+
email: user.email,
|
|
132
|
+
siteId: { in: workspaceIds },
|
|
133
|
+
promotedUserId: null,
|
|
134
|
+
},
|
|
135
|
+
select: { id: true, siteId: true, source: true },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (candidates.length === 0) {
|
|
139
|
+
return { ok: true, count: 0, promoted: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await prisma.lead.updateMany({
|
|
143
|
+
where: { id: { in: candidates.map((lead) => lead.id) } },
|
|
144
|
+
data: { promotedUserId: user.id, promotedAt: new Date() },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
ok: true,
|
|
149
|
+
count: candidates.length,
|
|
150
|
+
promoted: candidates.map((lead) => ({
|
|
151
|
+
leadId: lead.id,
|
|
152
|
+
siteId: lead.siteId,
|
|
153
|
+
source: lead.source,
|
|
154
|
+
})),
|
|
155
|
+
};
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
+
return { ok: false, error: { kind: "internal_error", message } };
|
|
159
|
+
}
|
|
160
|
+
}
|