create-ailk 0.21.0 → 0.23.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/component-catalog.json +186 -8
- package/component-catalog.md +9 -7
- package/dist/add-module.js +20 -14
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +12 -3
- package/dist/derive-architecture.js +20 -8
- package/dist/design-package.js +7 -2
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +5 -5
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +99 -9
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/programmatic.js +19 -5
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/site-chrome.d.ts +8 -0
- package/dist/site-chrome.js +17 -1
- package/dist/spoke-checkout.d.ts +65 -41
- package/dist/spoke-checkout.js +208 -430
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +52 -26
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +187 -13
- package/dist/template-record.d.ts +4 -3
- package/dist/template-record.js +8 -4
- package/dist/topology.js +25 -1
- package/dist/update.js +15 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +87 -17
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/consent-store.ts +28 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
- package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/auth.ts +13 -6
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +33 -3
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +268 -0
- package/templates/apps/api/src/routes/consent/index.ts +15 -7
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/get-page.ts +4 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
- package/templates/apps/api/src/routes/leads/index.ts +164 -7
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
- package/templates/apps/api/src/server.ts +163 -46
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
- package/templates/apps/api/src/services/consent.ts +95 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +431 -47
- package/templates/apps/api/src/services/magnet-completion.ts +242 -0
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +164 -17
- package/templates/apps/api/src/services/plan-offerings.ts +125 -0
- package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +24 -31
- package/templates/apps/web/CLAUDE.md +393 -9
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
- package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/auth.ts +28 -2
- package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
- package/templates/apps/web/lib/account-invite.ts +37 -0
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +16 -0
- package/templates/apps/web/lib/consent-policies.ts +22 -0
- package/templates/apps/web/lib/deck-page.ts +7 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
- package/templates/apps/web/lib/site-chrome.ts +2 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +71 -20
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/package.json +1 -0
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
- package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
- package/templates/apps/web/src/lib/lead-write.ts +90 -0
- package/templates/apps/web/src/lib/rate-limit.ts +79 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +69 -0
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
- package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
- package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +64 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/inbox/schema.prisma +17 -2
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +62 -3
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -60,6 +60,17 @@ jest.mock("@working-theory/stripe-client", () => ({
|
|
|
60
60
|
},
|
|
61
61
|
}));
|
|
62
62
|
|
|
63
|
+
// #8931 — the route now asks the Offerings service which path a request
|
|
64
|
+
// takes before the env checks; every case in this suite is an env-path case,
|
|
65
|
+
// so the service answers `env` for both the POST and the GET.
|
|
66
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
67
|
+
jest.mock("../../../services/offerings/checkout-authorization.js", () => ({
|
|
68
|
+
resolveOfferingsCheckoutPath: jest.fn(async () => ({ kind: "env" })),
|
|
69
|
+
readOfferingsCheckoutSession: jest.fn(async () => ({ kind: "env" })),
|
|
70
|
+
startOfferingsCheckout: jest.fn(),
|
|
71
|
+
OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE: 60,
|
|
72
|
+
}));
|
|
73
|
+
|
|
63
74
|
// Import after mock setup
|
|
64
75
|
import { config } from "../../../config/index.js";
|
|
65
76
|
import { checkoutRoutes } from "../index";
|
|
@@ -82,10 +82,22 @@ jest.mock("../../../lib/prisma.js", () => ({
|
|
|
82
82
|
},
|
|
83
83
|
}));
|
|
84
84
|
|
|
85
|
+
// #8931 — the route now asks the Offerings service which path a request
|
|
86
|
+
// takes before the env checks; every case in this suite is an env-path case,
|
|
87
|
+
// so the service answers `env` for both the POST and the GET.
|
|
88
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
89
|
+
jest.mock("../../../services/offerings/checkout-authorization.js", () => ({
|
|
90
|
+
resolveOfferingsCheckoutPath: jest.fn(async () => ({ kind: "env" })),
|
|
91
|
+
readOfferingsCheckoutSession: jest.fn(async () => ({ kind: "env" })),
|
|
92
|
+
startOfferingsCheckout: jest.fn(),
|
|
93
|
+
OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE: 60,
|
|
94
|
+
}));
|
|
95
|
+
|
|
85
96
|
// Import after mock setup
|
|
86
97
|
import { config } from "../../../config/index.js";
|
|
87
98
|
import { UnknownCustomerError } from "../../../lib/stripe.js";
|
|
88
99
|
import { prisma } from "../../../lib/prisma.js";
|
|
100
|
+
import { isOfferingsProduct } from "../../../services/offerings/catalog.js";
|
|
89
101
|
import { checkoutRoutes } from "../index";
|
|
90
102
|
|
|
91
103
|
// #6877 — pin the suite's site origin to the fixtures' own origin, so every
|
|
@@ -179,9 +191,23 @@ describe("POST /api/checkout/sessions", () => {
|
|
|
179
191
|
|
|
180
192
|
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
181
193
|
expect.objectContaining({ allowPromotionCodes: true }),
|
|
194
|
+
{ refuseProduct: isOfferingsProduct },
|
|
182
195
|
);
|
|
183
196
|
});
|
|
184
197
|
|
|
198
|
+
it("#8931 A9: an anonymous env checkout passes the Offerings marker as refuseProduct", async () => {
|
|
199
|
+
await app.inject({
|
|
200
|
+
method: "POST",
|
|
201
|
+
url: "/v1/checkout/sessions",
|
|
202
|
+
payload: VALID_BODY,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
|
|
206
|
+
const [, options] = mockCreateCheckoutSession.mock.calls[0];
|
|
207
|
+
expect(Object.keys(options)).toEqual(["refuseProduct"]);
|
|
208
|
+
expect(options.refuseProduct).toBe(isOfferingsProduct);
|
|
209
|
+
});
|
|
210
|
+
|
|
185
211
|
it("omits allowPromotionCodes from the client call when not supplied", async () => {
|
|
186
212
|
await app.inject({
|
|
187
213
|
method: "POST",
|
|
@@ -836,6 +862,7 @@ describe("parity with the payload the MCP tool builds (#5971)", () => {
|
|
|
836
862
|
cancelUrl: "https://example.com/cancelled",
|
|
837
863
|
allowPromotionCodes: true,
|
|
838
864
|
}),
|
|
865
|
+
{ refuseProduct: isOfferingsProduct },
|
|
839
866
|
);
|
|
840
867
|
});
|
|
841
868
|
|
|
@@ -912,6 +939,7 @@ describe("CheckoutSessionRequestSchema preserves Stripe's {CHECKOUT_SESSION_ID}
|
|
|
912
939
|
expect(res.statusCode).toBe(201);
|
|
913
940
|
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
914
941
|
expect.objectContaining({ successUrl: successUrlWithToken }),
|
|
942
|
+
{ refuseProduct: isOfferingsProduct },
|
|
915
943
|
);
|
|
916
944
|
});
|
|
917
945
|
});
|
|
@@ -1289,6 +1317,23 @@ describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
|
|
|
1289
1317
|
});
|
|
1290
1318
|
});
|
|
1291
1319
|
|
|
1320
|
+
it("#8931 A9: the first try and the anonymous retry both refuse an Offerings product with the marker itself", async () => {
|
|
1321
|
+
givenUser({ billingProvider: "stripe", billingCustomerId: "not_a_cus" });
|
|
1322
|
+
mockCreateCheckoutSession.mockRejectedValueOnce(
|
|
1323
|
+
Object.assign(new Error("invalid buyer"), { name: "ZodError" }),
|
|
1324
|
+
);
|
|
1325
|
+
|
|
1326
|
+
const res = await signedIn();
|
|
1327
|
+
|
|
1328
|
+
expect(res.statusCode).toBe(201);
|
|
1329
|
+
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(2);
|
|
1330
|
+
for (const call of mockCreateCheckoutSession.mock.calls) {
|
|
1331
|
+
expect(call).toHaveLength(2);
|
|
1332
|
+
expect(Object.keys(call[1])).toEqual(["refuseProduct"]);
|
|
1333
|
+
expect(call[1].refuseProduct).toBe(isOfferingsProduct);
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1292
1337
|
it("#6866 R12b: an anonymous ZodError is not retried — today's 502 mapping", async () => {
|
|
1293
1338
|
mockCreateCheckoutSession.mockRejectedValue(
|
|
1294
1339
|
Object.assign(new Error("invalid"), { name: "ZodError" }),
|
|
@@ -1474,6 +1519,7 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
|
|
|
1474
1519
|
expect(res.statusCode).toBe(201);
|
|
1475
1520
|
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
1476
1521
|
expect.objectContaining({ successUrl, cancelUrl }),
|
|
1522
|
+
{ refuseProduct: isOfferingsProduct },
|
|
1477
1523
|
);
|
|
1478
1524
|
});
|
|
1479
1525
|
|
|
@@ -1490,6 +1536,7 @@ describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", ()
|
|
|
1490
1536
|
expect(res.statusCode).toBe(201);
|
|
1491
1537
|
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
1492
1538
|
expect.objectContaining({ successUrl, cancelUrl }),
|
|
1539
|
+
{ refuseProduct: isOfferingsProduct },
|
|
1493
1540
|
);
|
|
1494
1541
|
});
|
|
1495
1542
|
|
|
@@ -1604,6 +1651,7 @@ describe("POST /v1/checkout/sessions — trusted-origin allowlist (#7395)", () =
|
|
|
1604
1651
|
expect(res.statusCode).toBe(201);
|
|
1605
1652
|
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
1606
1653
|
expect.objectContaining({ successUrl, cancelUrl }),
|
|
1654
|
+
{ refuseProduct: isOfferingsProduct },
|
|
1607
1655
|
);
|
|
1608
1656
|
});
|
|
1609
1657
|
|
|
@@ -1616,6 +1664,7 @@ describe("POST /v1/checkout/sessions — trusted-origin allowlist (#7395)", () =
|
|
|
1616
1664
|
expect(res.statusCode).toBe(201);
|
|
1617
1665
|
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
1618
1666
|
expect.objectContaining({ successUrl, cancelUrl }),
|
|
1667
|
+
{ refuseProduct: isOfferingsProduct },
|
|
1619
1668
|
);
|
|
1620
1669
|
});
|
|
1621
1670
|
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
* token-bearing response (D3, AC-9).
|
|
23
23
|
* - Deliberately NOT gated by `X-Internal-Auth` (D8 layer 4 — withdrawn;
|
|
24
24
|
* no `apps/web` file holds `OSS_API_INTERNAL_SECRET`).
|
|
25
|
+
* - A session the Offerings checkout recorded (#8931 D9, contract A13) is
|
|
26
|
+
* read with its record's own connection, in the record's mode, never the
|
|
27
|
+
* env key; its line-item names pass the catalog's T1 normalizer. A record
|
|
28
|
+
* that cannot be read, or a connection that is gone, unusable or in
|
|
29
|
+
* another mode, is 502 — never the env path. No record is today's read.
|
|
25
30
|
*
|
|
26
31
|
* Error mapping mirrors `sessions.ts`'s own vocabulary (D3):
|
|
27
32
|
* 400 validation-error — `:id` fails the shape check
|
|
@@ -29,7 +34,8 @@
|
|
|
29
34
|
* 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty
|
|
30
35
|
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
|
|
31
36
|
* 500 config-error — STRIPE_SECRET_KEY invalid configuration
|
|
32
|
-
* 502 checkout-unavailable — Stripe SDK threw an unexpected error
|
|
37
|
+
* 502 checkout-unavailable — Stripe SDK threw an unexpected error; a recorded
|
|
38
|
+
* Offerings session cannot be read on its connection (#8931)
|
|
33
39
|
*/
|
|
34
40
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
|
35
41
|
import { z } from "zod";
|
|
@@ -37,9 +43,11 @@ import { z } from "zod";
|
|
|
37
43
|
import {
|
|
38
44
|
assertAdmittedStripeKey,
|
|
39
45
|
retrieveCheckoutSession,
|
|
46
|
+
type RetrieveCheckoutSessionOutput,
|
|
40
47
|
StripeConfigError,
|
|
41
48
|
UnknownSessionError,
|
|
42
49
|
} from "../../lib/stripe.js";
|
|
50
|
+
import { readOfferingsCheckoutSession } from "../../services/offerings/checkout-authorization.js";
|
|
43
51
|
|
|
44
52
|
// A Stripe Checkout Session id is an unguessable bearer capability, not an
|
|
45
53
|
// authorization-checked resource (D8). The shape gate is layer 2 of D8's
|
|
@@ -63,6 +71,36 @@ function setPrivateResponseHeaders(reply: FastifyReply): void {
|
|
|
63
71
|
reply.header("Referrer-Policy", "no-referrer");
|
|
64
72
|
}
|
|
65
73
|
|
|
74
|
+
/** The 200: D3's headers and allow-list, named field-for-field. */
|
|
75
|
+
function sendSessionRead(
|
|
76
|
+
request: FastifyRequest,
|
|
77
|
+
reply: FastifyReply,
|
|
78
|
+
id: string,
|
|
79
|
+
session: RetrieveCheckoutSessionOutput,
|
|
80
|
+
): FastifyReply {
|
|
81
|
+
request.log.info(
|
|
82
|
+
{
|
|
83
|
+
method: request.method,
|
|
84
|
+
sessionIdPrefix: id.substring(0, 8),
|
|
85
|
+
status: 200,
|
|
86
|
+
},
|
|
87
|
+
"[checkout] session read",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
setPrivateResponseHeaders(reply);
|
|
91
|
+
// The D3 allow-list, named field-for-field — never a spread. Nothing
|
|
92
|
+
// else may join this object (AC-3's set-equality test pins exactly
|
|
93
|
+
// these six keys).
|
|
94
|
+
return reply.status(200).send({
|
|
95
|
+
id: session.id,
|
|
96
|
+
status: session.status,
|
|
97
|
+
paymentStatus: session.paymentStatus,
|
|
98
|
+
currency: session.currency,
|
|
99
|
+
amountTotal: session.amountTotal,
|
|
100
|
+
lineItems: session.lineItems,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
66
104
|
export async function checkoutSessionReadHandler(
|
|
67
105
|
request: FastifyRequest,
|
|
68
106
|
reply: FastifyReply,
|
|
@@ -75,6 +113,23 @@ export async function checkoutSessionReadHandler(
|
|
|
75
113
|
});
|
|
76
114
|
}
|
|
77
115
|
|
|
116
|
+
const { id } = parsed.data;
|
|
117
|
+
|
|
118
|
+
// A recorded Offerings session reads on its own connection (#8931 D9). No
|
|
119
|
+
// record can exist without a database.
|
|
120
|
+
if (process.env.DATABASE_URL) {
|
|
121
|
+
const offerings = await readOfferingsCheckoutSession(id);
|
|
122
|
+
if (offerings.kind === "read") {
|
|
123
|
+
return sendSessionRead(request, reply, id, offerings.session);
|
|
124
|
+
}
|
|
125
|
+
if (offerings.kind === "unknown") {
|
|
126
|
+
return reply.status(404).send({ error: "unknown-session" });
|
|
127
|
+
}
|
|
128
|
+
if (offerings.kind === "unavailable") {
|
|
129
|
+
return reply.status(502).send({ error: "checkout-unavailable" });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
78
133
|
const stripeSecretKey = process.env["STRIPE_SECRET_KEY"];
|
|
79
134
|
if (!stripeSecretKey) {
|
|
80
135
|
return reply.status(500).send({ error: "stripe-not-configured" });
|
|
@@ -85,32 +140,9 @@ export async function checkoutSessionReadHandler(
|
|
|
85
140
|
return reply.status(500).send({ error: "live-mode-forbidden" });
|
|
86
141
|
}
|
|
87
142
|
|
|
88
|
-
const { id } = parsed.data;
|
|
89
|
-
|
|
90
143
|
try {
|
|
91
144
|
const session = await retrieveCheckoutSession({ sessionId: id });
|
|
92
|
-
|
|
93
|
-
request.log.info(
|
|
94
|
-
{
|
|
95
|
-
method: request.method,
|
|
96
|
-
sessionIdPrefix: id.substring(0, 8),
|
|
97
|
-
status: 200,
|
|
98
|
-
},
|
|
99
|
-
"[checkout] session read",
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
setPrivateResponseHeaders(reply);
|
|
103
|
-
// The D3 allow-list, named field-for-field — never a spread. Nothing
|
|
104
|
-
// else may join this object (AC-3's set-equality test pins exactly
|
|
105
|
-
// these six keys).
|
|
106
|
-
return reply.status(200).send({
|
|
107
|
-
id: session.id,
|
|
108
|
-
status: session.status,
|
|
109
|
-
paymentStatus: session.paymentStatus,
|
|
110
|
-
currency: session.currency,
|
|
111
|
-
amountTotal: session.amountTotal,
|
|
112
|
-
lineItems: session.lineItems,
|
|
113
|
-
});
|
|
145
|
+
return sendSessionRead(request, reply, id, session);
|
|
114
146
|
} catch (err) {
|
|
115
147
|
if (err instanceof UnknownSessionError) {
|
|
116
148
|
// 404 — never echo the id in the response body.
|
|
@@ -13,9 +13,23 @@
|
|
|
13
13
|
* - No PII in logs: successUrl/cancelUrl and full session IDs are never logged.
|
|
14
14
|
* Only the first 8 chars of the session ID appear in log lines.
|
|
15
15
|
* - Error responses never echo user input (productCode, URLs).
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* - Two paths (#8931 D2, D3): for a server-resolved workspace whose
|
|
17
|
+
* Organization has a Stripe connection, the Offerings path —
|
|
18
|
+
* `services/offerings/checkout-authorization.ts` parses the key by the one
|
|
19
|
+
* grammar, authorizes it against org-enabled ∩ workspace-shown from the
|
|
20
|
+
* rows, sells the authorized Price id on the connection's own client in an
|
|
21
|
+
* anonymous session stamped `{ site, ailk_offering }`, and records it
|
|
22
|
+
* before this route answers. It never reads the env key, the snapshot or
|
|
23
|
+
* a caller value, charges a per-IP and a per-workspace bucket on every
|
|
24
|
+
* request, and never resolves a signed-in buyer. A spoke's workspace
|
|
25
|
+
* whose Organization has no row or no connection is the one 404 before
|
|
26
|
+
* any budget, env-key read or Stripe call — never the env path (#9126).
|
|
27
|
+
* Every other request takes the env path below, which now refuses a
|
|
28
|
+
* Price whose Product carries the Offerings marker (A9). The body schema
|
|
29
|
+
* is the same on both.
|
|
30
|
+
* - No DB write on the env path: it creates a Stripe session only. It READS
|
|
31
|
+
* the database to choose the path and to resolve an optional signed-in
|
|
32
|
+
* buyer (below).
|
|
19
33
|
* - Optional cookie identity (#6866): a valid, same-origin Better Auth
|
|
20
34
|
* session cookie whose user has a verified email makes the Checkout a
|
|
21
35
|
* signed-in one — the session is pinned to the buyer's Stripe customer and
|
|
@@ -48,13 +62,20 @@
|
|
|
48
62
|
* 401 internal-auth-required — X-Internal-Auth missing/mismatch (only when env var set)
|
|
49
63
|
* 400 validation-error — Zod validation failed (missing/invalid fields), or an
|
|
50
64
|
* unauthenticated redirect is off the site origin (#6877)
|
|
51
|
-
* 404 unknown-product-code — no active Stripe price for the given productCode
|
|
65
|
+
* 404 unknown-product-code — no active Stripe price for the given productCode; on the
|
|
66
|
+
* env path, a Price whose Product is Offerings-marked; on the
|
|
67
|
+
* Offerings path, every key that is not sellable here (#8931);
|
|
68
|
+
* every key, for a spoke whose Organization is unconnected (#9126)
|
|
69
|
+
* 429 rate_limited — Offerings path: the per-IP or per-workspace bucket (#8931 D4)
|
|
70
|
+
* 503 tenant_unresolved — a presented spoke key the resolver refused (#8931 A2)
|
|
52
71
|
* 500 stripe-not-configured — STRIPE_SECRET_KEY is missing/empty (no Stripe call)
|
|
53
72
|
* 500 live-mode-forbidden — STRIPE_SECRET_KEY is a live key the process did not admit
|
|
54
73
|
* 500 config-error — STRIPE_SECRET_KEY invalid configuration (from the SDK wrapper),
|
|
55
74
|
* or no trusted browser origin is configured when the origin
|
|
56
75
|
* lock applies (#6877, #7395)
|
|
57
|
-
* 502 checkout-unavailable — Stripe SDK threw an unexpected error
|
|
76
|
+
* 502 checkout-unavailable — Stripe SDK threw an unexpected error; a database read
|
|
77
|
+
* choosing the path failed; Offerings path: Stripe, the rows,
|
|
78
|
+
* the connection or the session record failed (#8931)
|
|
58
79
|
*/
|
|
59
80
|
import { timingSafeEqual } from "node:crypto";
|
|
60
81
|
|
|
@@ -78,10 +99,21 @@ import {
|
|
|
78
99
|
type OptionalCookieUser,
|
|
79
100
|
resolveOptionalCookieUser,
|
|
80
101
|
} from "../../middleware/auth.js";
|
|
102
|
+
import {
|
|
103
|
+
chargeWriteBudget,
|
|
104
|
+
DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
105
|
+
} from "../../middleware/rate-limit.js";
|
|
81
106
|
import {
|
|
82
107
|
type CheckoutBuyerResult,
|
|
83
108
|
resolveCheckoutBuyer,
|
|
84
109
|
} from "../../services/checkout-buyer.js";
|
|
110
|
+
import { isOfferingsProduct } from "../../services/offerings/catalog.js";
|
|
111
|
+
import {
|
|
112
|
+
OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE,
|
|
113
|
+
type OfferingsCheckoutPath,
|
|
114
|
+
resolveOfferingsCheckoutPath,
|
|
115
|
+
startOfferingsCheckout,
|
|
116
|
+
} from "../../services/offerings/checkout-authorization.js";
|
|
85
117
|
|
|
86
118
|
type OptionalCookieUserNone = Extract<OptionalCookieUser, { kind: "none" }>;
|
|
87
119
|
|
|
@@ -245,14 +277,97 @@ export async function checkoutSessionsHandler(
|
|
|
245
277
|
return reply.status(401).send({ error: "internal-auth-required" });
|
|
246
278
|
}
|
|
247
279
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return reply.status(500).send({ error: "stripe-not-configured" });
|
|
251
|
-
}
|
|
280
|
+
// ── Which path (#8931 D2) — from the server-resolved workspace alone ──────
|
|
281
|
+
let checkoutPath: OfferingsCheckoutPath;
|
|
252
282
|
try {
|
|
253
|
-
|
|
254
|
-
} catch {
|
|
255
|
-
|
|
283
|
+
checkoutPath = await resolveOfferingsCheckoutPath(request);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
request.log.error(
|
|
286
|
+
{
|
|
287
|
+
method: request.method,
|
|
288
|
+
path: request.url,
|
|
289
|
+
outcome: "offerings_path_failed",
|
|
290
|
+
errName: err instanceof Error ? err.name : "unknown",
|
|
291
|
+
},
|
|
292
|
+
"[checkout] path read failed; checkout-unavailable",
|
|
293
|
+
);
|
|
294
|
+
return reply.status(502).send({ error: "checkout-unavailable" });
|
|
295
|
+
}
|
|
296
|
+
if (checkoutPath.kind === "refused") {
|
|
297
|
+
return reply.status(503).send({ error: "tenant_unresolved" });
|
|
298
|
+
}
|
|
299
|
+
if (checkoutPath.kind === "unavailable") {
|
|
300
|
+
return reply.status(502).send({ error: "checkout-unavailable" });
|
|
301
|
+
}
|
|
302
|
+
if (checkoutPath.kind === "unconnected") {
|
|
303
|
+
// A spoke sells nothing until its Organization connects Stripe (#9126):
|
|
304
|
+
// the one 404 every miss answers, before any budget, env-key read or
|
|
305
|
+
// Stripe call, so the hub's env key never sells for a spoke.
|
|
306
|
+
request.log.warn(
|
|
307
|
+
{ outcome: "unconnected", workspaceId: checkoutPath.workspaceId },
|
|
308
|
+
"[checkout] spoke has no Stripe connection; unknown-product-code",
|
|
309
|
+
);
|
|
310
|
+
return reply.status(404).send({ error: "unknown-product-code" });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (checkoutPath.kind === "env") {
|
|
314
|
+
const stripeSecretKey = process.env["STRIPE_SECRET_KEY"];
|
|
315
|
+
if (!stripeSecretKey) {
|
|
316
|
+
return reply.status(500).send({ error: "stripe-not-configured" });
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
assertAdmittedStripeKey(stripeSecretKey);
|
|
320
|
+
} catch {
|
|
321
|
+
return reply.status(500).send({ error: "live-mode-forbidden" });
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
// The Offerings bucket (#8931 D4), before the body is read, whatever the
|
|
325
|
+
// process's mode: per caller IP, and per workspace — the bound a
|
|
326
|
+
// caller-set X-Forwarded-For cannot dodge. One charge at a time, the IP
|
|
327
|
+
// first: a request its IP bucket refuses never spends the workspace's,
|
|
328
|
+
// so one address cannot shut a seller's checkout for every buyer.
|
|
329
|
+
const charges = [
|
|
330
|
+
{ key: `ip,${request.ip}`, limit: DEFAULT_PER_IP_PER_MINUTE_STRICT },
|
|
331
|
+
{
|
|
332
|
+
key: `ws,${checkoutPath.workspaceId}`,
|
|
333
|
+
limit: OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE,
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
for (const charge of charges) {
|
|
337
|
+
let budget: Awaited<ReturnType<typeof chargeWriteBudget>>;
|
|
338
|
+
try {
|
|
339
|
+
budget = await chargeWriteBudget({
|
|
340
|
+
scope: "offeringscheckout",
|
|
341
|
+
charges: [charge],
|
|
342
|
+
windowMs: 60_000,
|
|
343
|
+
now: Date.now(),
|
|
344
|
+
warn: (obj, msg) => request.log.warn(obj, msg),
|
|
345
|
+
});
|
|
346
|
+
} catch (err) {
|
|
347
|
+
// A budget store that throws is a store failure, and the Offerings
|
|
348
|
+
// path's contract for one is 502 checkout-unavailable (code review on
|
|
349
|
+
// PR #9032) — never Fastify's 500.
|
|
350
|
+
request.log.error(
|
|
351
|
+
{
|
|
352
|
+
method: request.method,
|
|
353
|
+
path: request.url,
|
|
354
|
+
outcome: "offerings_budget_failed",
|
|
355
|
+
errName: err instanceof Error ? err.name : "unknown",
|
|
356
|
+
},
|
|
357
|
+
"[checkout] budget read failed; checkout-unavailable",
|
|
358
|
+
);
|
|
359
|
+
return reply.status(502).send({ error: "checkout-unavailable" });
|
|
360
|
+
}
|
|
361
|
+
if (!budget.ok) {
|
|
362
|
+
return reply
|
|
363
|
+
.header("Retry-After", String(budget.retryAfterSeconds))
|
|
364
|
+
.status(429)
|
|
365
|
+
.send({
|
|
366
|
+
error: "rate_limited",
|
|
367
|
+
retryAfterSeconds: budget.retryAfterSeconds,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
256
371
|
}
|
|
257
372
|
|
|
258
373
|
const parsed = CheckoutSessionRequestSchema.safeParse(request.body);
|
|
@@ -306,85 +421,120 @@ export async function checkoutSessionsHandler(
|
|
|
306
421
|
}
|
|
307
422
|
}
|
|
308
423
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
cancelUrl,
|
|
313
|
-
quantity: quantity ?? 1,
|
|
314
|
-
// Omitted when the caller says nothing — Stripe's default (no promo-code
|
|
315
|
-
// field) is unchanged for every existing caller.
|
|
316
|
-
...(allowPromotionCodes !== undefined && { allowPromotionCodes }),
|
|
317
|
-
metadata: siteStamp(),
|
|
318
|
-
};
|
|
319
|
-
// Built only from the session cookie — never from `parsed.data` (#6866 D5).
|
|
320
|
-
let { buyer, identity } = await resolveBuyer(request);
|
|
424
|
+
let created: CreateCheckoutSessionOutput;
|
|
425
|
+
let buyer: Buyer | undefined;
|
|
426
|
+
let identity: IdentityReason | undefined;
|
|
321
427
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
// maps exactly as today.
|
|
336
|
-
const reason: IdentityReason | undefined =
|
|
337
|
-
err instanceof UnknownCustomerError
|
|
338
|
-
? "stale_customer"
|
|
339
|
-
: err instanceof Error && err.name === "ZodError"
|
|
340
|
-
? "buyer_invalid"
|
|
341
|
-
: undefined;
|
|
342
|
-
if (buyer === undefined || reason === undefined) throw err;
|
|
343
|
-
buyer = undefined;
|
|
344
|
-
identity = reason;
|
|
345
|
-
created = await createCheckoutSession(sessionInput);
|
|
346
|
-
}
|
|
347
|
-
const { sessionId, url } = created;
|
|
348
|
-
|
|
349
|
-
request.log.info(
|
|
350
|
-
{
|
|
351
|
-
method: request.method,
|
|
352
|
-
path: request.url,
|
|
353
|
-
sessionIdPrefix: sessionId.substring(0, 8),
|
|
354
|
-
status: 201,
|
|
355
|
-
buyer: buyer !== undefined ? "signed_in" : "anonymous",
|
|
356
|
-
...(identity !== undefined && { identity }),
|
|
357
|
-
},
|
|
358
|
-
"[checkout] session created",
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
return reply.status(201).send({ sessionId, url });
|
|
362
|
-
} catch (err) {
|
|
363
|
-
if (err instanceof UnknownProductCodeError) {
|
|
364
|
-
// 404 — do NOT echo productCode in the response body
|
|
428
|
+
if (checkoutPath.kind === "offerings") {
|
|
429
|
+
// Anonymous by construction (#8931 C4): no buyer is resolved, and the
|
|
430
|
+
// session always allows promotion codes (A7, C3).
|
|
431
|
+
const outcome = await startOfferingsCheckout({
|
|
432
|
+
workspaceId: checkoutPath.workspaceId,
|
|
433
|
+
organizationId: checkoutPath.organizationId,
|
|
434
|
+
productCode,
|
|
435
|
+
successUrl,
|
|
436
|
+
cancelUrl,
|
|
437
|
+
quantity: quantity ?? 1,
|
|
438
|
+
});
|
|
439
|
+
if (outcome.kind === "unknown") {
|
|
440
|
+
// The one 404 every miss answers — never the key (A3, A5).
|
|
365
441
|
return reply.status(404).send({ error: "unknown-product-code" });
|
|
366
442
|
}
|
|
367
|
-
if (
|
|
368
|
-
|
|
443
|
+
if (outcome.kind === "unavailable") {
|
|
444
|
+
return reply.status(502).send({ error: "checkout-unavailable" });
|
|
445
|
+
}
|
|
446
|
+
created = outcome;
|
|
447
|
+
} else {
|
|
448
|
+
const sessionInput: CreateCheckoutSessionInput = {
|
|
449
|
+
productCode,
|
|
450
|
+
successUrl,
|
|
451
|
+
cancelUrl,
|
|
452
|
+
quantity: quantity ?? 1,
|
|
453
|
+
// Omitted when the caller says nothing — Stripe's default (no promo-code
|
|
454
|
+
// field) is unchanged for every existing caller.
|
|
455
|
+
...(allowPromotionCodes !== undefined && { allowPromotionCodes }),
|
|
456
|
+
metadata: siteStamp(),
|
|
457
|
+
};
|
|
458
|
+
// The env path never sells an Offerings product (#8931 A9, D8): the one
|
|
459
|
+
// marker rule decides it, on the first try and the anonymous retry.
|
|
460
|
+
const envOptions = { refuseProduct: isOfferingsProduct };
|
|
461
|
+
// Built only from the session cookie — never from `parsed.data` (#6866 D5).
|
|
462
|
+
({ buyer, identity } = await resolveBuyer(request));
|
|
463
|
+
|
|
464
|
+
try {
|
|
465
|
+
try {
|
|
466
|
+
created = await createCheckoutSession(
|
|
467
|
+
{
|
|
468
|
+
...sessionInput,
|
|
469
|
+
...(buyer !== undefined && { buyer }),
|
|
470
|
+
},
|
|
471
|
+
envOptions,
|
|
472
|
+
);
|
|
473
|
+
} catch (err) {
|
|
474
|
+
// The buyer itself cannot be used — a pinned customer Stripe does not
|
|
475
|
+
// know (#6866 D8), or a stored email / customer id the library's schema
|
|
476
|
+
// refuses (F7: no new failure mode). Retry once as anonymous; the
|
|
477
|
+
// stored binding is never touched here. The request fields already
|
|
478
|
+
// passed the route's schema, so an anonymous retry that still fails
|
|
479
|
+
// maps exactly as today.
|
|
480
|
+
const reason: IdentityReason | undefined =
|
|
481
|
+
err instanceof UnknownCustomerError
|
|
482
|
+
? "stale_customer"
|
|
483
|
+
: err instanceof Error && err.name === "ZodError"
|
|
484
|
+
? "buyer_invalid"
|
|
485
|
+
: undefined;
|
|
486
|
+
if (buyer === undefined || reason === undefined) throw err;
|
|
487
|
+
buyer = undefined;
|
|
488
|
+
identity = reason;
|
|
489
|
+
created = await createCheckoutSession(sessionInput, envOptions);
|
|
490
|
+
}
|
|
491
|
+
} catch (err) {
|
|
492
|
+
if (err instanceof UnknownProductCodeError) {
|
|
493
|
+
// 404 — do NOT echo productCode in the response body
|
|
494
|
+
return reply.status(404).send({ error: "unknown-product-code" });
|
|
495
|
+
}
|
|
496
|
+
if (err instanceof StripeConfigError) {
|
|
497
|
+
// 500 — misconfiguration; log message (no PII) but never echo in response
|
|
498
|
+
request.log.error(
|
|
499
|
+
{
|
|
500
|
+
method: request.method,
|
|
501
|
+
path: request.url,
|
|
502
|
+
errName: err.name,
|
|
503
|
+
message: err.message,
|
|
504
|
+
},
|
|
505
|
+
"[checkout] config error",
|
|
506
|
+
);
|
|
507
|
+
return reply.status(500).send({ error: "config-error" });
|
|
508
|
+
}
|
|
369
509
|
request.log.error(
|
|
370
510
|
{
|
|
371
511
|
method: request.method,
|
|
372
512
|
path: request.url,
|
|
373
|
-
errName: err.name,
|
|
374
|
-
message: err.message,
|
|
513
|
+
errName: err instanceof Error ? err.name : "unknown",
|
|
375
514
|
},
|
|
376
|
-
"[checkout]
|
|
515
|
+
"[checkout] stripe error; checkout-unavailable",
|
|
377
516
|
);
|
|
378
|
-
return reply.status(
|
|
517
|
+
return reply.status(502).send({ error: "checkout-unavailable" });
|
|
379
518
|
}
|
|
380
|
-
request.log.error(
|
|
381
|
-
{
|
|
382
|
-
method: request.method,
|
|
383
|
-
path: request.url,
|
|
384
|
-
errName: err instanceof Error ? err.name : "unknown",
|
|
385
|
-
},
|
|
386
|
-
"[checkout] stripe error; checkout-unavailable",
|
|
387
|
-
);
|
|
388
|
-
return reply.status(502).send({ error: "checkout-unavailable" });
|
|
389
519
|
}
|
|
520
|
+
|
|
521
|
+
const { sessionId, url } = created;
|
|
522
|
+
|
|
523
|
+
request.log.info(
|
|
524
|
+
{
|
|
525
|
+
method: request.method,
|
|
526
|
+
path: request.url,
|
|
527
|
+
sessionIdPrefix: sessionId.substring(0, 8),
|
|
528
|
+
status: 201,
|
|
529
|
+
buyer: buyer !== undefined ? "signed_in" : "anonymous",
|
|
530
|
+
...(identity !== undefined && { identity }),
|
|
531
|
+
...(checkoutPath.kind === "offerings" && {
|
|
532
|
+
checkout: "offerings",
|
|
533
|
+
workspaceId: checkoutPath.workspaceId,
|
|
534
|
+
}),
|
|
535
|
+
},
|
|
536
|
+
"[checkout] session created",
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
return reply.status(201).send({ sessionId, url });
|
|
390
540
|
}
|