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
|
@@ -63,6 +63,8 @@ const UNVERIFIED_SOURCES: ReadonlySet<ConsentSource> = new Set([
|
|
|
63
63
|
"api",
|
|
64
64
|
"cookie_banner",
|
|
65
65
|
"legacy",
|
|
66
|
+
// #8765 D13, C10: the payer at a Stripe Checkout is not a signed-in user.
|
|
67
|
+
"checkout",
|
|
66
68
|
]);
|
|
67
69
|
|
|
68
70
|
/** Whether a row from `source` is unconfirmed by the client (D4). */
|
|
@@ -176,6 +178,32 @@ export async function anySourceConsentRows(
|
|
|
176
178
|
ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC`;
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Whether this Person already holds the `checkout` row of one Checkout
|
|
183
|
+
* Session for one purpose (#8765 D14, NFR7) — the webhook's replay check.
|
|
184
|
+
* Filters on `(workspace_id, person_id, purpose_id)` first, so the existing
|
|
185
|
+
* `(workspace_id, person_id, purpose_id, created_at)` index serves it
|
|
186
|
+
* (SEC-8765-N).
|
|
187
|
+
*/
|
|
188
|
+
export async function personHasCheckoutGrant(
|
|
189
|
+
workspaceId: string,
|
|
190
|
+
personId: string,
|
|
191
|
+
purposeId: string,
|
|
192
|
+
sessionId: string,
|
|
193
|
+
db: ConsentReadClient = prisma,
|
|
194
|
+
): Promise<boolean> {
|
|
195
|
+
const rows = await db.$queryRaw<Array<{ found: number }>>`
|
|
196
|
+
SELECT 1 AS "found"
|
|
197
|
+
FROM "consent_grants" g
|
|
198
|
+
WHERE g."workspace_id" = ${workspaceId}
|
|
199
|
+
AND g."person_id" = ${personId}
|
|
200
|
+
AND g."purpose_id" = ${purposeId}
|
|
201
|
+
AND g."source" = 'checkout'
|
|
202
|
+
AND g."source_ref" = ${sessionId}
|
|
203
|
+
LIMIT 1`;
|
|
204
|
+
return rows.length > 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
179
207
|
/**
|
|
180
208
|
* A Person's OWN reading, per registry purpose (D4). The member's screens and
|
|
181
209
|
* the sign-up prompt read only this.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* - Leaves replies that handlers already committed (explicit status + body)
|
|
17
17
|
* untouched
|
|
18
18
|
*/
|
|
19
|
+
import { idRegex } from "@working-theory/ids";
|
|
19
20
|
import type { FastifyError, FastifyInstance } from "fastify";
|
|
20
21
|
|
|
21
22
|
const GENERIC_INTERNAL_BODY = { error: "internal_error" } as const;
|
|
@@ -30,16 +31,82 @@ const GENERIC_INTERNAL_BODY = { error: "internal_error" } as const;
|
|
|
30
31
|
* EVERY route. Logging the raw target would write that credential into the
|
|
31
32
|
* API's own log the moment anything went wrong, which is precisely when logs
|
|
32
33
|
* get read, copied into tickets, and shipped to third parties.
|
|
33
|
-
*
|
|
34
|
-
* The path itself is kept: it is what makes the line useful, and a route
|
|
35
|
-
* parameter like `:id` is an identifier rather than a secret. The matching
|
|
36
|
-
* strip lives on the Fastify logger's own `req` serializer in `server.ts`.
|
|
37
34
|
*/
|
|
38
35
|
function pathWithoutQuery(url: string): string {
|
|
39
36
|
const queryAt = url.indexOf("?");
|
|
40
37
|
return queryAt === -1 ? url : url.slice(0, queryAt);
|
|
41
38
|
}
|
|
42
39
|
|
|
40
|
+
/**
|
|
41
|
+
* #6084 D14 — `GET /v1/checkout/sessions/:id` puts its credential in the
|
|
42
|
+
* PATH, so a `cs_…`-shaped segment is truncated to its first 8 characters
|
|
43
|
+
* plus `…` (a path SEGMENT rule, never a substring replace, so it cannot
|
|
44
|
+
* corrupt an unrelated URL). #6216 D11 adds the same arm for `lead_…`
|
|
45
|
+
* segments and #8438 SEC-8438-10 for `person_…` segments, which would
|
|
46
|
+
* otherwise put a full id beside `remoteAddress`.
|
|
47
|
+
*/
|
|
48
|
+
const SESSION_ID_LOG_SEGMENT = /^cs_[A-Za-z0-9_]+$/;
|
|
49
|
+
const LEAD_ID_LOG_SEGMENT = idRegex("lead");
|
|
50
|
+
const PERSON_ID_LOG_SEGMENT = idRegex("person");
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* #8932 D10 (contract W9) — a webhook connection token is the per-connection
|
|
54
|
+
* receiver's routing credential (`/v1/webhooks/<provider>/:connectionToken`).
|
|
55
|
+
* Every segment after the provider is replaced by one literal
|
|
56
|
+
* `:connectionToken`, keeping no prefix — unlike the id arms above — and
|
|
57
|
+
* empty segments are dropped first, so a trailing slash, a double slash or a
|
|
58
|
+
* mis-pasted extra segment cannot carry the token past the rule.
|
|
59
|
+
*/
|
|
60
|
+
const CONNECTION_TOKEN_PLACEHOLDER = ":connectionToken";
|
|
61
|
+
|
|
62
|
+
/** A segment as the router matches it: percent-decoded, or raw when it does not decode. */
|
|
63
|
+
function decodedSegment(segment: string): string {
|
|
64
|
+
try {
|
|
65
|
+
return decodeURIComponent(segment);
|
|
66
|
+
} catch {
|
|
67
|
+
return segment;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function redactConnectionToken(path: string): string | null {
|
|
72
|
+
// Compared DECODED: the router decodes before it matches, so
|
|
73
|
+
// `/v1/%77ebhooks/stripe/<token>` reaches the token route and must be
|
|
74
|
+
// redacted like its plain spelling.
|
|
75
|
+
const segments = path
|
|
76
|
+
.split("/")
|
|
77
|
+
.filter((segment) => segment.length > 0)
|
|
78
|
+
.map(decodedSegment);
|
|
79
|
+
if (segments.length < 4 || segments[0] !== "v1" || segments[1] !== "webhooks") {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return `/${segments.slice(0, 3).join("/")}/${CONNECTION_TOKEN_PLACEHOLDER}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The ONE path redactor for a log line (#8932 D10): the query string dropped,
|
|
87
|
+
* a webhook connection token replaced whole, and every `cs_…`, `lead_…` and
|
|
88
|
+
* `person_…` segment truncated. The Fastify request serializer (`server.ts`)
|
|
89
|
+
* and this handler's three `path:` fields both call it, so a 429, 413, 415 or
|
|
90
|
+
* 5xx on a token route logs the same redacted path its request line does.
|
|
91
|
+
* A route parameter that is an identifier rather than a secret — `:id` — is
|
|
92
|
+
* kept: it is what makes the line useful.
|
|
93
|
+
*/
|
|
94
|
+
export function redactLogPath(url: string): string {
|
|
95
|
+
const path = pathWithoutQuery(url);
|
|
96
|
+
const tokenless = redactConnectionToken(path);
|
|
97
|
+
if (tokenless !== null) return tokenless;
|
|
98
|
+
return path
|
|
99
|
+
.split("/")
|
|
100
|
+
.map((segment) =>
|
|
101
|
+
SESSION_ID_LOG_SEGMENT.test(segment) ||
|
|
102
|
+
LEAD_ID_LOG_SEGMENT.test(segment) ||
|
|
103
|
+
PERSON_ID_LOG_SEGMENT.test(segment)
|
|
104
|
+
? `${segment.slice(0, 8)}…`
|
|
105
|
+
: segment,
|
|
106
|
+
)
|
|
107
|
+
.join("/");
|
|
108
|
+
}
|
|
109
|
+
|
|
43
110
|
export function registerErrorSerializer(server: FastifyInstance): void {
|
|
44
111
|
server.setErrorHandler(async (err: FastifyError, request, reply) => {
|
|
45
112
|
// Validation errors (Fastify's schema validator) — keep structured detail.
|
|
@@ -49,7 +116,7 @@ export function registerErrorSerializer(server: FastifyInstance): void {
|
|
|
49
116
|
request.log.warn(
|
|
50
117
|
{
|
|
51
118
|
method: request.method,
|
|
52
|
-
path:
|
|
119
|
+
path: redactLogPath(request.url),
|
|
53
120
|
validation: err.validation,
|
|
54
121
|
},
|
|
55
122
|
"request validation failed",
|
|
@@ -68,7 +135,7 @@ export function registerErrorSerializer(server: FastifyInstance): void {
|
|
|
68
135
|
request.log.info(
|
|
69
136
|
{
|
|
70
137
|
method: request.method,
|
|
71
|
-
path:
|
|
138
|
+
path: redactLogPath(request.url),
|
|
72
139
|
statusCode: status,
|
|
73
140
|
errName: err.name,
|
|
74
141
|
},
|
|
@@ -83,7 +150,7 @@ export function registerErrorSerializer(server: FastifyInstance): void {
|
|
|
83
150
|
request.log.error(
|
|
84
151
|
{
|
|
85
152
|
method: request.method,
|
|
86
|
-
path:
|
|
153
|
+
path: redactLogPath(request.url),
|
|
87
154
|
errName: err.name,
|
|
88
155
|
errMessage: err.message,
|
|
89
156
|
errStack: err.stack,
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A magnet's completion setting — its data boundary (#8757, spec
|
|
3
|
+
* docs/specs/8757-a-per-magnet-completion-setting-spec.md D3).
|
|
4
|
+
*
|
|
5
|
+
* `flow_definitions.completion` / `checkout_offering` are new columns, and
|
|
6
|
+
* `lib/tenant-db.ts` — the magnet routes' only other data path — stays closed
|
|
7
|
+
* under the parallel contract with #8746 (D7), so the two columns get this
|
|
8
|
+
* named boundary of their own. Every statement is scoped by a server-resolved
|
|
9
|
+
* `workspaceId` (a tenant's, or the public lead route's resolved workspace):
|
|
10
|
+
*
|
|
11
|
+
* - `completionsFor` — one `findMany` by workspace AND id list;
|
|
12
|
+
* - `setCompletion` — one `updateMany` by id AND workspace, writing
|
|
13
|
+
* exactly the two columns;
|
|
14
|
+
* - `liveCompletionBySlug` — one `findFirst` of a LIVE magnet by workspace
|
|
15
|
+
* AND slug (its pair, title and config — the
|
|
16
|
+
* config for a declared `completionEmail`);
|
|
17
|
+
* - `inviteSendCounts` — two `lead.count`s for the invite email's caps
|
|
18
|
+
* (D6), by workspace AND `flow:<slug>` source.
|
|
19
|
+
*
|
|
20
|
+
* No list without an id set, no other model, no read by any other key.
|
|
21
|
+
* Base tree (SEC-8757-D): no `oss: false` marker and no `MODULE_SLICES`
|
|
22
|
+
* entry, because the never-gate lead route imports it. `architecture.yaml`
|
|
23
|
+
* lists this file under the tenant-scope rule's `paths_scoped` with exactly
|
|
24
|
+
* these four call sites' counts, so a further access fails the lint.
|
|
25
|
+
*/
|
|
26
|
+
import {
|
|
27
|
+
FLOW_COMPLETION_DEFAULT,
|
|
28
|
+
flowCompletionSettingSchema,
|
|
29
|
+
type FlowCompletionSetting,
|
|
30
|
+
} from "@working-theory/validation";
|
|
31
|
+
|
|
32
|
+
import { prisma } from "./prisma.js";
|
|
33
|
+
|
|
34
|
+
const DEFAULT_SETTING: FlowCompletionSetting = {
|
|
35
|
+
completion: FLOW_COMPLETION_DEFAULT,
|
|
36
|
+
checkoutOffering: null,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A stored pair as the vocabulary reads it. The database's CHECKs make every
|
|
41
|
+
* stored pair valid; a row that carries neither column (a test fixture, or a
|
|
42
|
+
* read before the migration) reads as the default, as `customersEnabled` does.
|
|
43
|
+
*/
|
|
44
|
+
function toSetting(row: { completion?: unknown; checkoutOffering?: unknown }) {
|
|
45
|
+
const parsed = flowCompletionSettingSchema.safeParse({
|
|
46
|
+
completion: row.completion,
|
|
47
|
+
checkoutOffering: row.checkoutOffering ?? null,
|
|
48
|
+
});
|
|
49
|
+
return parsed.success ? parsed.data : DEFAULT_SETTING;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The setting of each of `ids` in `workspaceId`. An id absent from the map reads as `lead`. */
|
|
53
|
+
export async function completionsFor(
|
|
54
|
+
workspaceId: string,
|
|
55
|
+
ids: readonly string[],
|
|
56
|
+
): Promise<Map<string, FlowCompletionSetting>> {
|
|
57
|
+
const out = new Map<string, FlowCompletionSetting>();
|
|
58
|
+
if (ids.length === 0) return out;
|
|
59
|
+
const rows = await prisma.flowDefinition.findMany({
|
|
60
|
+
where: { workspaceId, id: { in: [...ids] } },
|
|
61
|
+
select: { id: true, completion: true, checkoutOffering: true },
|
|
62
|
+
});
|
|
63
|
+
for (const row of rows) out.set(row.id, toSetting(row));
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `completionsFor`'s answer for one id. */
|
|
68
|
+
export function completionOf(
|
|
69
|
+
settings: Map<string, FlowCompletionSetting>,
|
|
70
|
+
id: string,
|
|
71
|
+
): FlowCompletionSetting {
|
|
72
|
+
return settings.get(id) ?? DEFAULT_SETTING;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Write the pair onto magnet `id` of `workspaceId`; nothing else on the row. */
|
|
76
|
+
export async function setCompletion(
|
|
77
|
+
workspaceId: string,
|
|
78
|
+
id: string,
|
|
79
|
+
setting: FlowCompletionSetting,
|
|
80
|
+
): Promise<"set" | "not_found"> {
|
|
81
|
+
const result = await prisma.flowDefinition.updateMany({
|
|
82
|
+
where: { id, workspaceId },
|
|
83
|
+
data: { completion: setting.completion, checkoutOffering: setting.checkoutOffering },
|
|
84
|
+
});
|
|
85
|
+
return result.count > 0 ? "set" : "not_found";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type LiveCompletion = FlowCompletionSetting & {
|
|
89
|
+
title: string;
|
|
90
|
+
/** The stored config, unparsed — the invite email reads its `completionEmail`. */
|
|
91
|
+
config: unknown;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The LIVE magnet at `slug` in `workspaceId`: its setting, title and config, or `null`. */
|
|
95
|
+
export async function liveCompletionBySlug(
|
|
96
|
+
workspaceId: string,
|
|
97
|
+
slug: string,
|
|
98
|
+
): Promise<LiveCompletion | null> {
|
|
99
|
+
const row = await prisma.flowDefinition.findFirst({
|
|
100
|
+
where: { workspaceId, slug, status: "live" },
|
|
101
|
+
select: { title: true, config: true, completion: true, checkoutOffering: true },
|
|
102
|
+
});
|
|
103
|
+
return row === null ? null : { ...toSetting(row), title: row.title, config: row.config };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
107
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* D6's two caps, counted in the database so they hold across instances and
|
|
111
|
+
* against a spoofed `x-forwarded-for`: this magnet's leads for `email`
|
|
112
|
+
* (case-insensitively) over the last day, and all of its leads over the last
|
|
113
|
+
* hour. Both include the lead the caller just wrote.
|
|
114
|
+
*/
|
|
115
|
+
export async function inviteSendCounts(
|
|
116
|
+
workspaceId: string,
|
|
117
|
+
slug: string,
|
|
118
|
+
email: string,
|
|
119
|
+
now: Date,
|
|
120
|
+
): Promise<{ recipientDay: number; siteHour: number }> {
|
|
121
|
+
const source = `flow:${slug}`;
|
|
122
|
+
const [recipientDay, siteHour] = await Promise.all([
|
|
123
|
+
prisma.lead.count({
|
|
124
|
+
where: {
|
|
125
|
+
workspaceId,
|
|
126
|
+
source,
|
|
127
|
+
email: { equals: email, mode: "insensitive" },
|
|
128
|
+
createdAt: { gte: new Date(now.getTime() - DAY_MS) },
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
prisma.lead.count({
|
|
132
|
+
where: { workspaceId, source, createdAt: { gte: new Date(now.getTime() - HOUR_MS) } },
|
|
133
|
+
}),
|
|
134
|
+
]);
|
|
135
|
+
return { recipientDay, siteHour };
|
|
136
|
+
}
|
|
@@ -102,6 +102,38 @@ export async function findClientByClientId(
|
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** What the hub's sign-in page may show about a spoke (#8790 D2). */
|
|
106
|
+
export interface SignInClientInfo {
|
|
107
|
+
clientId: string;
|
|
108
|
+
/** The spoke's display name from the registry. */
|
|
109
|
+
name: string;
|
|
110
|
+
/** The origin of the client's first registered redirect URI. */
|
|
111
|
+
origin: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The public shape of one spoke's sign-in client, or `null` when the client
|
|
116
|
+
* is missing, disabled, or its spoke is revoked (#8790 D2). Only the
|
|
117
|
+
* registry decides what a sign-in page may name — never a query parameter.
|
|
118
|
+
*/
|
|
119
|
+
export async function findSignInClientInfo(
|
|
120
|
+
clientId: string,
|
|
121
|
+
): Promise<SignInClientInfo | null> {
|
|
122
|
+
const client = await findClientByClientId(clientId);
|
|
123
|
+
if (client === null || !client.enabled) return null;
|
|
124
|
+
const spoke = await findSpokeById(clientId);
|
|
125
|
+
if (spoke === null || spoke.revokedAt !== null) return null;
|
|
126
|
+
const first = client.redirectUris[0];
|
|
127
|
+
if (first === undefined) return null;
|
|
128
|
+
let origin: string;
|
|
129
|
+
try {
|
|
130
|
+
origin = new URL(first).origin;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return { clientId, name: spoke.name, origin };
|
|
135
|
+
}
|
|
136
|
+
|
|
105
137
|
/**
|
|
106
138
|
* Create or update a spoke's sign-in client (the operator CLI). Re-running
|
|
107
139
|
* rotates the secret: `clientSecret` is replaced by the new digest, so the
|
|
@@ -161,7 +193,10 @@ export function canonicalRedirectUri(raw: string): string | null {
|
|
|
161
193
|
|
|
162
194
|
export type RegisterSignInClientResult =
|
|
163
195
|
| { ok: true; spokeId: string; secret: string }
|
|
164
|
-
| {
|
|
196
|
+
| {
|
|
197
|
+
ok: false;
|
|
198
|
+
reason: "invalid-redirect-uri" | "spoke-not-found" | "spoke-revoked";
|
|
199
|
+
};
|
|
165
200
|
|
|
166
201
|
/**
|
|
167
202
|
* The operator CLI's `sign-in-client` (D3): refuse an invalid callback, an
|
|
@@ -173,7 +208,8 @@ export async function registerSignInClient(params: {
|
|
|
173
208
|
redirectUri: string;
|
|
174
209
|
}): Promise<RegisterSignInClientResult> {
|
|
175
210
|
const redirectUri = canonicalRedirectUri(params.redirectUri);
|
|
176
|
-
if (redirectUri === null)
|
|
211
|
+
if (redirectUri === null)
|
|
212
|
+
return { ok: false, reason: "invalid-redirect-uri" };
|
|
177
213
|
|
|
178
214
|
const spoke = await findSpokeById(params.spokeId);
|
|
179
215
|
if (!spoke) return { ok: false, reason: "spoke-not-found" };
|
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* every scaffold without the member slice (the #5677 failure). Its one piece
|
|
9
9
|
* write therefore lives here, in the base tree, importing only `prisma.ts`.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Three statements and no others, all scoped by a workspace id AND a piece id
|
|
12
12
|
* taken from a Stripe-signed `checkout.session.completed` event's metadata —
|
|
13
13
|
* written by this server when it opened the session (`ailk_proposal`,
|
|
14
14
|
* `ailk_workspace`; `services/proposal-payments.ts`), never settable by the
|
|
15
|
-
* payer: a guarded `updateMany` that stamps an unpaid proposal paid,
|
|
16
|
-
* `findFirst` by the same pair reading back which session paid it
|
|
17
|
-
*
|
|
15
|
+
* payer: a guarded `updateMany` that stamps an unpaid proposal paid, a
|
|
16
|
+
* `findFirst` by the same pair reading back which session paid it, and a
|
|
17
|
+
* `findFirst` by the same pair reading the proposal's Person — the subject of
|
|
18
|
+
* the checkout's terms grant (#8765 D14). No list, no other model, no read by
|
|
19
|
+
* any other key; never called by a route.
|
|
18
20
|
* `architecture.yaml` names this file as a tenant-scope exclusion with that
|
|
19
21
|
* reason.
|
|
20
22
|
*/
|
|
@@ -47,3 +49,18 @@ export async function stampProposalPaid(
|
|
|
47
49
|
if (row === null) return "not_found";
|
|
48
50
|
return row.paidSessionId === sessionId ? "replay" : "already_paid";
|
|
49
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The proposal's Person (#8765 D14), or `null` when no proposal of that id
|
|
55
|
+
* is in that workspace.
|
|
56
|
+
*/
|
|
57
|
+
export async function proposalPersonId(
|
|
58
|
+
workspaceId: string,
|
|
59
|
+
pieceId: string,
|
|
60
|
+
): Promise<string | null> {
|
|
61
|
+
const row = await prisma.piece.findFirst({
|
|
62
|
+
where: { id: pieceId, workspaceId, kind: "proposal" },
|
|
63
|
+
select: { personId: true },
|
|
64
|
+
});
|
|
65
|
+
return row?.personId ?? null;
|
|
66
|
+
}
|
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* '@working-theory/stripe-client' — single chokepoint for AILK-internal ergonomics
|
|
9
9
|
* (logger context, request-id propagation) without touching every consumer.
|
|
10
10
|
*/
|
|
11
|
+
import { logger } from "@working-theory/observability";
|
|
11
12
|
import {
|
|
12
13
|
admitLiveRestrictedKey,
|
|
13
14
|
assertSandboxKey,
|
|
15
|
+
createStripeClientForKey,
|
|
14
16
|
getStripeClient,
|
|
15
17
|
StripeConfigError,
|
|
18
|
+
type StripeConnectionMode,
|
|
16
19
|
} from "@working-theory/stripe-client";
|
|
17
20
|
|
|
18
21
|
export {
|
|
@@ -26,24 +29,37 @@ export {
|
|
|
26
29
|
// Both are on main (#6850, #6862); #8450 is the narrowing this guarded —
|
|
27
30
|
// `assertAdmittedStripeKey` below. This export itself stays sandbox-only.
|
|
28
31
|
assertSandboxKey,
|
|
32
|
+
// The plan checkout's mode assertion and terms-box failure (#9067 D3 step 9).
|
|
33
|
+
CheckoutModeMismatchError,
|
|
34
|
+
// The Organization connection's key classifier (#8906, #8907 D4).
|
|
35
|
+
classifyStripeKey,
|
|
29
36
|
createAdHocPrice,
|
|
30
37
|
createBillingPortalSession,
|
|
31
38
|
createCheckoutSession,
|
|
39
|
+
// The Offerings checkout's per-client calls (#8931 D6), on the connection's client.
|
|
40
|
+
createCheckoutSessionForPrice,
|
|
32
41
|
createPaymentLink,
|
|
33
42
|
createQuotedCheckoutSession,
|
|
43
|
+
expireCheckoutSessionWith,
|
|
44
|
+
findActivePriceByLookupKey,
|
|
34
45
|
getStripeClient,
|
|
46
|
+
// Working Theory's own absolute policies for the plan checkout's terms box (#9067 D3 step 9).
|
|
47
|
+
PLATFORM_TERMS_OF_SERVICE,
|
|
35
48
|
// Checkout session read (#6084 D9) — re-exported through this sole
|
|
36
49
|
// Stripe-touching chokepoint, same as every other Stripe call here.
|
|
37
50
|
retrieveCheckoutSession,
|
|
38
51
|
STRIPE_API_VERSION,
|
|
39
52
|
StripeConfigError,
|
|
40
53
|
StripeWebhookSignatureError,
|
|
54
|
+
TermsOfServiceUrlMissingError,
|
|
41
55
|
toSubscriptionLifecycleStatus,
|
|
42
56
|
// Signed-in Checkout (#6866 D8) — a pinned customer Stripe rejects.
|
|
43
57
|
UnknownCustomerError,
|
|
44
58
|
UnknownProductCodeError,
|
|
45
59
|
UnknownSessionError,
|
|
46
60
|
verifyWebhookSignature,
|
|
61
|
+
// The per-Organization webhook's verify (#8932 D3): the row's secret, no env key.
|
|
62
|
+
verifyWebhookSignatureKeyless,
|
|
47
63
|
WEBHOOK_TOLERANCE_SECONDS,
|
|
48
64
|
} from "@working-theory/stripe-client";
|
|
49
65
|
|
|
@@ -55,6 +71,7 @@ export type {
|
|
|
55
71
|
CreateBillingPortalSessionOutput,
|
|
56
72
|
CreatePaymentLinkInput,
|
|
57
73
|
CreatePaymentLinkOutput,
|
|
74
|
+
CreateCheckoutSessionForPriceInput,
|
|
58
75
|
CreateCheckoutSessionInput,
|
|
59
76
|
CreateCheckoutSessionOutput,
|
|
60
77
|
CreateCheckoutSessionParams,
|
|
@@ -64,6 +81,8 @@ export type {
|
|
|
64
81
|
RetrieveCheckoutSessionOutput,
|
|
65
82
|
Stripe,
|
|
66
83
|
StripeCheckoutSession,
|
|
84
|
+
StripeConnectionMode,
|
|
85
|
+
StripeKeyClass,
|
|
67
86
|
StripeWebhookEvent,
|
|
68
87
|
} from "@working-theory/stripe-client";
|
|
69
88
|
|
|
@@ -81,9 +100,10 @@ let liveKeyAdmitted = false;
|
|
|
81
100
|
* rule, with one fail-closed delta (PR #8470 security review R-1): on Vercel
|
|
82
101
|
* (`VERCEL` set) a missing `VERCEL_ENV` is not production, because
|
|
83
102
|
* `vercel.json` sets `NODE_ENV=production` in every environment, Preview
|
|
84
|
-
* included.
|
|
103
|
+
* included. Exported for the Organization connection's go-live check and
|
|
104
|
+
* its live-row guard (#8907 D3, D8 step 6); the body is unchanged.
|
|
85
105
|
*/
|
|
86
|
-
function isProductionBuild(): boolean {
|
|
106
|
+
export function isProductionBuild(): boolean {
|
|
87
107
|
if (process.env.VERCEL_ENV !== undefined) {
|
|
88
108
|
return process.env.VERCEL_ENV === "production";
|
|
89
109
|
}
|
|
@@ -145,6 +165,35 @@ export function assertAdmittedStripeKey(key: string): void {
|
|
|
145
165
|
assertSandboxKey(key);
|
|
146
166
|
}
|
|
147
167
|
|
|
168
|
+
/**
|
|
169
|
+
* A fresh Stripe client from ONE Organization's own key (#8907 D12, #8906
|
|
170
|
+
* F-T1 to F-T3), or `null` when the factory refuses it: the factory's only
|
|
171
|
+
* caller, so `productionBuild` is always the strict predicate above and never
|
|
172
|
+
* a caller's boolean. It reads and sets no process flag — `stripeKeyMode()`
|
|
173
|
+
* is untouched — so one process holds a live Organization and a test one
|
|
174
|
+
* (contract K6, K7). A refusal logs `{ organizationId, reason }` only.
|
|
175
|
+
* `architecture.yaml` confines its callers to exact paths.
|
|
176
|
+
*/
|
|
177
|
+
export function stripeClientForConnection(input: {
|
|
178
|
+
organizationId: string;
|
|
179
|
+
key: string;
|
|
180
|
+
mode: StripeConnectionMode;
|
|
181
|
+
liveAt: Date | null;
|
|
182
|
+
}): ReturnType<typeof createStripeClientForKey> {
|
|
183
|
+
return createStripeClientForKey({
|
|
184
|
+
organizationId: input.organizationId,
|
|
185
|
+
key: input.key,
|
|
186
|
+
mode: input.mode,
|
|
187
|
+
liveAt: input.liveAt,
|
|
188
|
+
productionBuild: isProductionBuild(),
|
|
189
|
+
log: (refusal) =>
|
|
190
|
+
logger.warn(
|
|
191
|
+
{ organizationId: refusal.organizationId, reason: refusal.reason },
|
|
192
|
+
"[stripe] connection key refused for its mode",
|
|
193
|
+
),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
148
197
|
/** The key class this process admitted at boot: `"live"` only after `admitApiStripeKey()` did. */
|
|
149
198
|
export function stripeKeyMode(): "test" | "live" {
|
|
150
199
|
return liveKeyAdmitted ? "live" : "test";
|
|
@@ -165,14 +214,22 @@ export async function expireCheckoutSession(stripeSessionId: string): Promise<vo
|
|
|
165
214
|
await getStripeClient().checkout.sessions.expire(stripeSessionId);
|
|
166
215
|
}
|
|
167
216
|
|
|
168
|
-
/**
|
|
217
|
+
/**
|
|
218
|
+
* #8171 D7 — what the Billing page shows: the plan's name and the card on
|
|
219
|
+
* file. #9067 D6 adds the renewal date and the site count, read from the
|
|
220
|
+
* same subscription.
|
|
221
|
+
*/
|
|
169
222
|
export type CustomerBillingSummary = {
|
|
170
223
|
planName: string | null;
|
|
171
224
|
paymentMethod: { brand: string; last4: string; expMonth: number; expYear: number } | null;
|
|
225
|
+
renewsAt: string | null;
|
|
226
|
+
sites: number | null;
|
|
172
227
|
};
|
|
173
228
|
|
|
174
229
|
/** A subscription status that still names the customer's plan (#8171 C10). */
|
|
175
230
|
const CURRENT_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due", "unpaid"]);
|
|
231
|
+
/** A subscription status that renews at its period end (#9067 C3). */
|
|
232
|
+
const RENEWING_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due"]);
|
|
176
233
|
const PLAN_NAME_MAX = 200;
|
|
177
234
|
|
|
178
235
|
/**
|
|
@@ -224,7 +281,20 @@ export async function readCustomerBillingSummary(
|
|
|
224
281
|
?.default_payment_method ?? null;
|
|
225
282
|
}
|
|
226
283
|
|
|
227
|
-
|
|
284
|
+
// #9067 C3, D6 — under `dahlia` the period end lives on the item, and a
|
|
285
|
+
// subscription set to cancel names no renewal.
|
|
286
|
+
const item = current?.items.data[0];
|
|
287
|
+
const renews =
|
|
288
|
+
current !== undefined &&
|
|
289
|
+
RENEWING_SUBSCRIPTION_STATUSES.has(current.status) &&
|
|
290
|
+
current.cancel_at_period_end !== true &&
|
|
291
|
+
current.cancel_at == null &&
|
|
292
|
+
typeof item?.current_period_end === "number";
|
|
293
|
+
const renewsAt = renews ? new Date(item.current_period_end * 1000).toISOString() : null;
|
|
294
|
+
const quantity = item?.quantity;
|
|
295
|
+
const sites = typeof quantity === "number" && Number.isInteger(quantity) && quantity >= 1 ? quantity : null;
|
|
296
|
+
|
|
297
|
+
return { planName, paymentMethod: cardOf(method), renewsAt, sites };
|
|
228
298
|
}
|
|
229
299
|
|
|
230
300
|
/** A `card` payment method's presentation fields, or `null` for anything else. */
|
|
@@ -82,11 +82,75 @@ export function markSeen(eventId: string, nowMs: number = Date.now()): void {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// ─── Per-Organization store (#8932 D8, contract W10) ─────────────────────────
|
|
86
|
+
//
|
|
87
|
+
// The per-Organization endpoint (`POST /v1/webhooks/stripe/:connectionToken`)
|
|
88
|
+
// dedupes in its OWN map, keyed `<organizationId>:<eventId>`, so neither
|
|
89
|
+
// endpoint's dedup can swallow the other's event (SEC-8879-1) and two
|
|
90
|
+
// Organizations sharing one Stripe account never collide (an Organization id
|
|
91
|
+
// holds no `:`). Same TTL, soft cap and sweep rules as the legacy pair above;
|
|
92
|
+
// the legacy `hasSeen`/`markSeen` never read it. Correctness rests on the
|
|
93
|
+
// lead's database key, not on this map.
|
|
94
|
+
|
|
95
|
+
const orgStore = new Map<string, Entry>();
|
|
96
|
+
let orgInsertsSinceLastSweep = 0;
|
|
97
|
+
|
|
98
|
+
function orgKey(organizationId: string, eventId: string): string {
|
|
99
|
+
return `${organizationId}:${eventId}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sweepOrgExpired(nowMs: number): void {
|
|
103
|
+
for (const [key, entry] of orgStore) {
|
|
104
|
+
if (nowMs - entry.insertedAtMs >= IDEMPOTENCY_TTL_MS) {
|
|
105
|
+
orgStore.delete(key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
orgInsertsSinceLastSweep = 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `hasSeen`'s twin for one Organization's endpoint. */
|
|
112
|
+
export function hasSeenForOrganization(
|
|
113
|
+
organizationId: string,
|
|
114
|
+
eventId: string,
|
|
115
|
+
nowMs: number = Date.now(),
|
|
116
|
+
): boolean {
|
|
117
|
+
const key = orgKey(organizationId, eventId);
|
|
118
|
+
const entry = orgStore.get(key);
|
|
119
|
+
if (!entry) return false;
|
|
120
|
+
if (nowMs - entry.insertedAtMs >= IDEMPOTENCY_TTL_MS) {
|
|
121
|
+
orgStore.delete(key);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `markSeen`'s twin for one Organization's endpoint. */
|
|
128
|
+
export function markSeenForOrganization(
|
|
129
|
+
organizationId: string,
|
|
130
|
+
eventId: string,
|
|
131
|
+
nowMs: number = Date.now(),
|
|
132
|
+
): void {
|
|
133
|
+
const key = orgKey(organizationId, eventId);
|
|
134
|
+
if (!orgStore.has(key)) {
|
|
135
|
+
orgStore.set(key, { insertedAtMs: nowMs });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
orgInsertsSinceLastSweep += 1;
|
|
139
|
+
if (
|
|
140
|
+
orgStore.size > SOFT_CAP ||
|
|
141
|
+
orgInsertsSinceLastSweep >= SWEEP_INTERVAL_INSERTS
|
|
142
|
+
) {
|
|
143
|
+
sweepOrgExpired(nowMs);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
85
147
|
/**
|
|
86
|
-
* Test-only reset. Clears all dedup state
|
|
87
|
-
* interfere with each other.
|
|
148
|
+
* Test-only reset. Clears all dedup state — both stores — between test cases
|
|
149
|
+
* so they do not interfere with each other.
|
|
88
150
|
*/
|
|
89
151
|
export function __resetIdempotencyStore(): void {
|
|
90
152
|
store.clear();
|
|
91
153
|
insertsSinceLastSweep = 0;
|
|
154
|
+
orgStore.clear();
|
|
155
|
+
orgInsertsSinceLastSweep = 0;
|
|
92
156
|
}
|