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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* istanbul ignore file -- CLI bootstrap; rekeyStripeConnections and
|
|
3
|
+
formatRekeyReport are unit-tested in
|
|
4
|
+
services/offerings/__tests__/connection.test.ts. */
|
|
5
|
+
/**
|
|
6
|
+
* Re-encrypt every Organization Stripe connection under the current
|
|
7
|
+
* STRIPE_CONNECTION_KEY (#8907 D13, contract C10).
|
|
8
|
+
*
|
|
9
|
+
* Dev: `pnpm --filter @working-theory/api stripe-connections:rekey` (tsx,
|
|
10
|
+
* this file). Production: `node dist/bin/stripe-connections-rekey.js`.
|
|
11
|
+
*
|
|
12
|
+
* The rotation: set STRIPE_CONNECTION_KEY_PREVIOUS to the old key and the new
|
|
13
|
+
* key as STRIPE_CONNECTION_KEY, redeploy, run this, and unset _PREVIOUS once
|
|
14
|
+
* it prints `remaining=0`. A row counted `unreadable` decrypts under neither
|
|
15
|
+
* key; its owner reconnects or disconnects. A second run rekeys nothing.
|
|
16
|
+
*
|
|
17
|
+
* Run it with the production environment — `VERCEL_ENV=production`, or
|
|
18
|
+
* `NODE_ENV=production` off Vercel — the same predicate the API goes live on;
|
|
19
|
+
* anywhere else it prints `not_production`, reads nothing and exits 1.
|
|
20
|
+
*
|
|
21
|
+
* Prints one line of counts only — never a key, a value or a row id — and
|
|
22
|
+
* exits 1 when the key is not configured, off production, or the walk fails.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
formatRekeyReport,
|
|
26
|
+
rekeyStripeConnections,
|
|
27
|
+
} from "../services/offerings/connection.js";
|
|
28
|
+
|
|
29
|
+
async function main(): Promise<void> {
|
|
30
|
+
const result = await rekeyStripeConnections();
|
|
31
|
+
if (!result.ok) {
|
|
32
|
+
console.error(formatRekeyReport(result));
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log(formatRekeyReport(result));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
main().catch((err: unknown) => {
|
|
40
|
+
// The class name only: a store error's message is fixed, but nothing else
|
|
41
|
+
// a failure carries is printed.
|
|
42
|
+
const name = err instanceof Error ? err.name : "unknown";
|
|
43
|
+
console.error(`[stripe-connections-rekey] failed: ${name}`);
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file stripe-connection-key.test.ts
|
|
3
|
+
* @description STRIPE_CONNECTION_KEY and STRIPE_CONNECTION_KEY_PREVIOUS — the
|
|
4
|
+
* keys an Organization's Stripe connection is encrypted under (#8907 D3,
|
|
5
|
+
* AC-9; contract C3, C10).
|
|
6
|
+
*
|
|
7
|
+
* `resolveStripeConnectionKeys` reads both with the scheduling key's shape
|
|
8
|
+
* rule (canonical standard base64 of exactly 32 bytes), refuses a previous
|
|
9
|
+
* key with no current one or equal to it, and refuses either when it is the
|
|
10
|
+
* scheduling key — so one key never guards calendars and money. A refused
|
|
11
|
+
* key stops the API at startup; a refusal names the variable and the reason
|
|
12
|
+
* kind, never any part of the value. Unset is a supported state.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Mock dotenv so a developer's local .env never leaks into the config module.
|
|
16
|
+
jest.mock("dotenv", () => ({
|
|
17
|
+
config: jest.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const mockLoggerError = jest.fn();
|
|
21
|
+
jest.mock("@working-theory/observability", () => ({
|
|
22
|
+
logger: {
|
|
23
|
+
error: (...args: unknown[]) => mockLoggerError(...args),
|
|
24
|
+
warn: jest.fn(),
|
|
25
|
+
info: jest.fn(),
|
|
26
|
+
debug: jest.fn(),
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { randomBytes } from "node:crypto";
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
|
|
34
|
+
import { resolveStripeConnectionKeys } from "../index";
|
|
35
|
+
|
|
36
|
+
const A = randomBytes(32);
|
|
37
|
+
const B = randomBytes(32);
|
|
38
|
+
const S = randomBytes(32);
|
|
39
|
+
const b64 = (bytes: Buffer) => bytes.toString("base64");
|
|
40
|
+
|
|
41
|
+
/** 32 bytes whose base64 differs from its base64url, so the url form is refused. */
|
|
42
|
+
function urlOnlyKey(): string {
|
|
43
|
+
let bytes = randomBytes(32);
|
|
44
|
+
while (!/[+/]/.test(bytes.toString("base64"))) bytes = randomBytes(32);
|
|
45
|
+
return bytes.toString("base64url");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("resolveStripeConnectionKeys (#8907 D3)", () => {
|
|
49
|
+
it("answers both buffers for two distinct canonical 32-byte keys", () => {
|
|
50
|
+
const result = resolveStripeConnectionKeys({ current: b64(A), previous: b64(B), scheduling: S });
|
|
51
|
+
expect(result.kind).toBe("ok");
|
|
52
|
+
if (result.kind !== "ok") throw new Error("expected ok");
|
|
53
|
+
expect(result.current?.equals(A)).toBe(true);
|
|
54
|
+
expect(result.previous?.equals(B)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("answers ok with nothing when both are unset or empty", () => {
|
|
58
|
+
expect(resolveStripeConnectionKeys({ current: undefined, previous: "", scheduling: S })).toEqual({
|
|
59
|
+
kind: "ok",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("answers the current key alone with no previous", () => {
|
|
64
|
+
const result = resolveStripeConnectionKeys({ current: b64(A), previous: undefined, scheduling: undefined });
|
|
65
|
+
expect(result).toEqual({ kind: "ok", current: A });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const urlKey = urlOnlyKey();
|
|
69
|
+
const shortKey = b64(randomBytes(16));
|
|
70
|
+
it.each([
|
|
71
|
+
["a base64url current key", { current: urlKey }, "STRIPE_CONNECTION_KEY", "not-canonical-base64", urlKey],
|
|
72
|
+
["a 16-byte current key", { current: shortKey }, "STRIPE_CONNECTION_KEY", "length", shortKey],
|
|
73
|
+
[
|
|
74
|
+
"a 16-byte previous key",
|
|
75
|
+
{ current: b64(A), previous: shortKey },
|
|
76
|
+
"STRIPE_CONNECTION_KEY_PREVIOUS",
|
|
77
|
+
"length",
|
|
78
|
+
shortKey,
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
"a previous key with no current",
|
|
82
|
+
{ previous: b64(B) },
|
|
83
|
+
"STRIPE_CONNECTION_KEY_PREVIOUS",
|
|
84
|
+
"previous_without_current",
|
|
85
|
+
b64(B),
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
"equal current and previous",
|
|
89
|
+
{ current: b64(A), previous: b64(A) },
|
|
90
|
+
"STRIPE_CONNECTION_KEY_PREVIOUS",
|
|
91
|
+
"previous_equals_current",
|
|
92
|
+
b64(A),
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
"a current key equal to the scheduling key",
|
|
96
|
+
{ current: b64(S) },
|
|
97
|
+
"STRIPE_CONNECTION_KEY",
|
|
98
|
+
"shared_with_scheduling",
|
|
99
|
+
b64(S),
|
|
100
|
+
],
|
|
101
|
+
[
|
|
102
|
+
"a previous key equal to the scheduling key",
|
|
103
|
+
{ current: b64(A), previous: b64(S) },
|
|
104
|
+
"STRIPE_CONNECTION_KEY_PREVIOUS",
|
|
105
|
+
"shared_with_scheduling",
|
|
106
|
+
b64(S),
|
|
107
|
+
],
|
|
108
|
+
])("refuses %s, naming the variable and the reason only", (_label, raw, variable, reason, value) => {
|
|
109
|
+
const result = resolveStripeConnectionKeys({
|
|
110
|
+
current: (raw as { current?: string }).current,
|
|
111
|
+
previous: (raw as { previous?: string }).previous,
|
|
112
|
+
scheduling: S,
|
|
113
|
+
});
|
|
114
|
+
expect(result).toEqual({ kind: "refused", variable, reason });
|
|
115
|
+
expect(JSON.stringify(result)).not.toContain(value.slice(0, 8));
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("config.STRIPE_CONNECTION_KEY at startup (#8907 D3)", () => {
|
|
120
|
+
const originalEnv = process.env;
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
jest.resetModules();
|
|
124
|
+
process.env = { ...originalEnv };
|
|
125
|
+
delete process.env.AILK_ENABLED_MODULES;
|
|
126
|
+
delete process.env.AILK_TRUSTED_ORIGINS;
|
|
127
|
+
delete process.env.SCHEDULING_CREDENTIAL_KEY;
|
|
128
|
+
delete process.env.STRIPE_CONNECTION_KEY;
|
|
129
|
+
delete process.env.STRIPE_CONNECTION_KEY_PREVIOUS;
|
|
130
|
+
jest.clearAllMocks();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
process.env = originalEnv;
|
|
135
|
+
jest.restoreAllMocks();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("exposes the decoded keys when they are valid, and nothing when unset", () => {
|
|
139
|
+
process.env.STRIPE_CONNECTION_KEY = b64(A);
|
|
140
|
+
process.env.STRIPE_CONNECTION_KEY_PREVIOUS = b64(B);
|
|
141
|
+
const { config } = require("../index");
|
|
142
|
+
expect(config.STRIPE_CONNECTION_KEY.equals(A)).toBe(true);
|
|
143
|
+
expect(config.STRIPE_CONNECTION_KEY_PREVIOUS.equals(B)).toBe(true);
|
|
144
|
+
|
|
145
|
+
jest.resetModules();
|
|
146
|
+
delete process.env.STRIPE_CONNECTION_KEY;
|
|
147
|
+
delete process.env.STRIPE_CONNECTION_KEY_PREVIOUS;
|
|
148
|
+
const { config: unset } = require("../index");
|
|
149
|
+
expect(unset.STRIPE_CONNECTION_KEY).toBeUndefined();
|
|
150
|
+
expect(unset.STRIPE_CONNECTION_KEY_PREVIOUS).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it.each([
|
|
154
|
+
["a 31-byte key", { STRIPE_CONNECTION_KEY: b64(randomBytes(31)) }],
|
|
155
|
+
["the scheduling key", { STRIPE_CONNECTION_KEY: b64(S), SCHEDULING_CREDENTIAL_KEY: b64(S) }],
|
|
156
|
+
["a previous key alone", { STRIPE_CONNECTION_KEY_PREVIOUS: b64(B) }],
|
|
157
|
+
])("exits on a refused value (%s), logging the variable but never the value", (_label, env) => {
|
|
158
|
+
Object.assign(process.env, env);
|
|
159
|
+
const exitSpy = jest.spyOn(process, "exit").mockImplementation(() => {
|
|
160
|
+
throw new Error("Process exit called");
|
|
161
|
+
});
|
|
162
|
+
const consoleError = jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
163
|
+
|
|
164
|
+
expect(() => require("../index")).toThrow("Process exit called");
|
|
165
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
166
|
+
expect(mockLoggerError).toHaveBeenCalledTimes(1);
|
|
167
|
+
|
|
168
|
+
const logged = JSON.stringify([mockLoggerError.mock.calls, consoleError.mock.calls]);
|
|
169
|
+
expect(logged).toContain("STRIPE_CONNECTION_KEY");
|
|
170
|
+
for (const value of Object.values(env)) {
|
|
171
|
+
expect(logged).not.toContain(value);
|
|
172
|
+
expect(logged).not.toContain(value.slice(0, 12));
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("the .env.example files (#8907 D3)", () => {
|
|
178
|
+
const REPO_ROOT = join(__dirname, "..", "..", "..", "..", "..");
|
|
179
|
+
|
|
180
|
+
it.each([".env.example", "apps/api/.env.example"])("%s documents both variables, commented out", (file) => {
|
|
181
|
+
const text = readFileSync(join(REPO_ROOT, file), "utf8");
|
|
182
|
+
expect(text).toMatch(/^# STRIPE_CONNECTION_KEY=$/m);
|
|
183
|
+
expect(text).toMatch(/^# STRIPE_CONNECTION_KEY_PREVIOUS=$/m);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -112,6 +112,15 @@ export interface Config {
|
|
|
112
112
|
// env-configured CAL_API_KEY path works exactly as before. Held decoded,
|
|
113
113
|
// never as the raw string, and never logged.
|
|
114
114
|
SCHEDULING_CREDENTIAL_KEY?: Buffer;
|
|
115
|
+
// An Organization's Stripe connection (#8907 D3, contract C3, C10) — the
|
|
116
|
+
// AES-256-GCM key services/offerings/connection.ts encrypts each
|
|
117
|
+
// connection's restricted key and webhook secret under, and the key it
|
|
118
|
+
// replaced during a rotation. The SCHEDULING_CREDENTIAL_KEY shape rule,
|
|
119
|
+
// checked at startup by resolveStripeConnectionKeys() below; a refused
|
|
120
|
+
// value stops the API. Optional: unset (or empty) means a connection can be
|
|
121
|
+
// read and removed but not written. Held decoded, never logged.
|
|
122
|
+
STRIPE_CONNECTION_KEY?: Buffer;
|
|
123
|
+
STRIPE_CONNECTION_KEY_PREVIOUS?: Buffer;
|
|
115
124
|
// What a hub does with a request to a spoke-aware public surface (meeting
|
|
116
125
|
// types, the three lead writes, booking, availability, event meta, the
|
|
117
126
|
// public content reads) that carries no `x-ailk-spoke-key` (#7532 D7,
|
|
@@ -258,6 +267,82 @@ export function resolveSchedulingCredentialKey(
|
|
|
258
267
|
return { kind: 'ok', key };
|
|
259
268
|
}
|
|
260
269
|
|
|
270
|
+
/** The two variables an Organization's Stripe connection key is read from (#8907 D3). */
|
|
271
|
+
export type StripeConnectionKeyVariable =
|
|
272
|
+
| 'STRIPE_CONNECTION_KEY'
|
|
273
|
+
| 'STRIPE_CONNECTION_KEY_PREVIOUS';
|
|
274
|
+
|
|
275
|
+
/** Why a Stripe connection key was refused (#8907 D3). */
|
|
276
|
+
export type StripeConnectionKeyRefusal =
|
|
277
|
+
| SchedulingCredentialKeyRefusal
|
|
278
|
+
| 'previous_without_current'
|
|
279
|
+
| 'previous_equals_current'
|
|
280
|
+
| 'shared_with_scheduling';
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The Stripe connection keys (#8907 D3, contract C3, C10). Pure;
|
|
284
|
+
* `validateConfig()` calls it once.
|
|
285
|
+
*
|
|
286
|
+
* Each raw value is read with `resolveSchedulingCredentialKey`, the one shape
|
|
287
|
+
* rule. A previous key needs a current one, may not equal it, and neither may
|
|
288
|
+
* equal the scheduling key (`Buffer.equals`), so one key never guards both
|
|
289
|
+
* calendars and money. A refusal names the variable and the reason kind only,
|
|
290
|
+
* never any part of a value.
|
|
291
|
+
*/
|
|
292
|
+
export function resolveStripeConnectionKeys(input: {
|
|
293
|
+
current: string | undefined;
|
|
294
|
+
previous: string | undefined;
|
|
295
|
+
scheduling: Buffer | undefined;
|
|
296
|
+
}):
|
|
297
|
+
| { kind: 'ok'; current?: Buffer; previous?: Buffer }
|
|
298
|
+
| {
|
|
299
|
+
kind: 'refused';
|
|
300
|
+
variable: StripeConnectionKeyVariable;
|
|
301
|
+
reason: StripeConnectionKeyRefusal;
|
|
302
|
+
} {
|
|
303
|
+
const current = resolveSchedulingCredentialKey(input.current);
|
|
304
|
+
if (current.kind === 'refused') {
|
|
305
|
+
return { kind: 'refused', variable: 'STRIPE_CONNECTION_KEY', reason: current.reason };
|
|
306
|
+
}
|
|
307
|
+
const previous = resolveSchedulingCredentialKey(input.previous);
|
|
308
|
+
if (previous.kind === 'refused') {
|
|
309
|
+
return { kind: 'refused', variable: 'STRIPE_CONNECTION_KEY_PREVIOUS', reason: previous.reason };
|
|
310
|
+
}
|
|
311
|
+
const currentKey = current.kind === 'ok' ? current.key : undefined;
|
|
312
|
+
const previousKey = previous.kind === 'ok' ? previous.key : undefined;
|
|
313
|
+
if (previousKey && !currentKey) {
|
|
314
|
+
return {
|
|
315
|
+
kind: 'refused',
|
|
316
|
+
variable: 'STRIPE_CONNECTION_KEY_PREVIOUS',
|
|
317
|
+
reason: 'previous_without_current',
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
if (previousKey && currentKey?.equals(previousKey)) {
|
|
321
|
+
return {
|
|
322
|
+
kind: 'refused',
|
|
323
|
+
variable: 'STRIPE_CONNECTION_KEY_PREVIOUS',
|
|
324
|
+
reason: 'previous_equals_current',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
if (input.scheduling) {
|
|
328
|
+
if (currentKey?.equals(input.scheduling)) {
|
|
329
|
+
return { kind: 'refused', variable: 'STRIPE_CONNECTION_KEY', reason: 'shared_with_scheduling' };
|
|
330
|
+
}
|
|
331
|
+
if (previousKey?.equals(input.scheduling)) {
|
|
332
|
+
return {
|
|
333
|
+
kind: 'refused',
|
|
334
|
+
variable: 'STRIPE_CONNECTION_KEY_PREVIOUS',
|
|
335
|
+
reason: 'shared_with_scheduling',
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
kind: 'ok',
|
|
341
|
+
...(currentKey && { current: currentKey }),
|
|
342
|
+
...(previousKey && { previous: previousKey }),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
261
346
|
/**
|
|
262
347
|
* `AILK_HUB_REQUIRE_SPOKE_KEY` (#7532 D7, #7633 D2): unset, empty or blank →
|
|
263
348
|
* `"auto"`; `true`/`1` → `"on"`; `false`/`0` → `"off"` — case-insensitive,
|
|
@@ -364,6 +449,24 @@ function validateConfig(): Config {
|
|
|
364
449
|
process.exit(1);
|
|
365
450
|
}
|
|
366
451
|
|
|
452
|
+
// #8907 D3 — a set-but-refused Stripe connection key stops the API in every
|
|
453
|
+
// environment, for the scheduling key's reason; so does a key shared with
|
|
454
|
+
// the scheduling key or a previous key that could never be rotated away.
|
|
455
|
+
// The variable and the reason kind only, never a value.
|
|
456
|
+
const stripeConnectionKeys = resolveStripeConnectionKeys({
|
|
457
|
+
current: process.env.STRIPE_CONNECTION_KEY,
|
|
458
|
+
previous: process.env.STRIPE_CONNECTION_KEY_PREVIOUS,
|
|
459
|
+
scheduling: schedulingKey.kind === 'ok' ? schedulingKey.key : undefined,
|
|
460
|
+
});
|
|
461
|
+
if (stripeConnectionKeys.kind === 'refused') {
|
|
462
|
+
const { variable, reason } = stripeConnectionKeys;
|
|
463
|
+
const message =
|
|
464
|
+
'❌ STRIPE_CONNECTION_KEY and STRIPE_CONNECTION_KEY_PREVIOUS must each be canonical standard base64 of exactly 32 bytes (openssl rand -base64 32), distinct from each other and from SCHEDULING_CREDENTIAL_KEY, and a previous key needs a current one';
|
|
465
|
+
logger.error({ variable, reason }, message);
|
|
466
|
+
console.error(message, JSON.stringify({ variable, reason }));
|
|
467
|
+
process.exit(1);
|
|
468
|
+
}
|
|
469
|
+
|
|
367
470
|
// #7532 D7, #7633 D2 — the hub's spoke-key mode. An unparseable value stops
|
|
368
471
|
// the API: a typo read as "off" would silently leave the hub open to
|
|
369
472
|
// header-less callers. The message names the variable, never its value.
|
|
@@ -479,6 +582,12 @@ function validateConfig(): Config {
|
|
|
479
582
|
...(schedulingKey.kind === 'ok' && {
|
|
480
583
|
SCHEDULING_CREDENTIAL_KEY: schedulingKey.key,
|
|
481
584
|
}),
|
|
585
|
+
...(stripeConnectionKeys.current && {
|
|
586
|
+
STRIPE_CONNECTION_KEY: stripeConnectionKeys.current,
|
|
587
|
+
}),
|
|
588
|
+
...(stripeConnectionKeys.previous && {
|
|
589
|
+
STRIPE_CONNECTION_KEY_PREVIOUS: stripeConnectionKeys.previous,
|
|
590
|
+
}),
|
|
482
591
|
AILK_HUB_REQUIRE_SPOKE_KEY: hubRequireSpokeKey,
|
|
483
592
|
AILK_SPOKE_FLOW_SEED: spokeFlowSeed,
|
|
484
593
|
};
|
|
@@ -203,6 +203,9 @@ export const prisma = {
|
|
|
203
203
|
findFirst: jest.fn(),
|
|
204
204
|
findMany: jest.fn(),
|
|
205
205
|
updateMany: jest.fn(),
|
|
206
|
+
// `count` — the flow engine's completion-email caps (#8847 security
|
|
207
|
+
// review). Unarranged it resolves undefined, which caps nothing.
|
|
208
|
+
count: jest.fn(),
|
|
206
209
|
},
|
|
207
210
|
subscriber: {
|
|
208
211
|
create: jest.fn(),
|
|
@@ -135,6 +135,25 @@ describe("the two readings (SEC-8438-1)", () => {
|
|
|
135
135
|
).toBe(true);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
it("a checkout row is unverified: in the Person's any-source reading, never their own (#8765 D13, C10)", async () => {
|
|
139
|
+
expect(isUnverifiedSource("checkout")).toBe(true);
|
|
140
|
+
double.ledger.push(
|
|
141
|
+
row({
|
|
142
|
+
purposeId: "data",
|
|
143
|
+
granted: true,
|
|
144
|
+
source: "checkout",
|
|
145
|
+
sourceRef: "cs_live_abc123",
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
const own = await ownConsentState(WS, PERSON, CONFIG);
|
|
149
|
+
const any = await anySourceConsentState(WS, PERSON, CONFIG);
|
|
150
|
+
expect(own.find((s) => s.purposeId === "data")?.row).toBeNull();
|
|
151
|
+
expect(any.find((s) => s.purposeId === "data")?.row).toMatchObject({
|
|
152
|
+
source: "checkout",
|
|
153
|
+
sourceRef: "cs_live_abc123",
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
138
157
|
it("another workspace's rows and another Person's rows never show", async () => {
|
|
139
158
|
double.ledger.push(
|
|
140
159
|
row({ workspaceId: OTHER_WS, purposeId: "data", granted: true }),
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file flow-completion-store.test.ts
|
|
3
|
+
*
|
|
4
|
+
* A magnet's completion setting — its data boundary (#8757, spec
|
|
5
|
+
* docs/specs/8757-a-per-magnet-completion-setting-spec.md D3, AC-3): every
|
|
6
|
+
* statement is scoped by the workspace it is given, so a write naming another
|
|
7
|
+
* workspace's magnet answers `not_found` and changes nothing, a draft magnet
|
|
8
|
+
* is never a live completion, and the invite caps count only this magnet's
|
|
9
|
+
* leads of this workspace in their window.
|
|
10
|
+
*
|
|
11
|
+
* The world is the in-memory Prisma double (`__tests__/factories/in-memory-prisma`),
|
|
12
|
+
* which refuses a filter it does not model — so scoping that rides on one
|
|
13
|
+
* cannot pass silently.
|
|
14
|
+
*/
|
|
15
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
16
|
+
jest.mock("../prisma.js", () =>
|
|
17
|
+
jest
|
|
18
|
+
.requireActual<typeof import("../../__tests__/factories/in-memory-prisma")>(
|
|
19
|
+
"../../__tests__/factories/in-memory-prisma",
|
|
20
|
+
)
|
|
21
|
+
.createInMemoryPrisma(),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
import { prisma } from "../prisma.js";
|
|
25
|
+
import {
|
|
26
|
+
completionOf,
|
|
27
|
+
completionsFor,
|
|
28
|
+
inviteSendCounts,
|
|
29
|
+
liveCompletionBySlug,
|
|
30
|
+
setCompletion,
|
|
31
|
+
} from "../flow-completion-store.js";
|
|
32
|
+
|
|
33
|
+
type Row = Record<string, unknown>;
|
|
34
|
+
// eslint-disable-next-line no-restricted-syntax -- the in-memory double exposes its store on keys the real client type does not declare
|
|
35
|
+
const fake = prisma as unknown as { __db: Record<string, Row[]> };
|
|
36
|
+
|
|
37
|
+
const WS_A1 = "wksp_00000000-0000-7000-8000-0000000000a1";
|
|
38
|
+
const WS_A2 = "wksp_00000000-0000-7000-8000-0000000000a2";
|
|
39
|
+
const M1 = "flow_00000000-0000-7000-8000-000000000001";
|
|
40
|
+
const M2 = "flow_00000000-0000-7000-8000-000000000002";
|
|
41
|
+
const DRAFT = "flow_00000000-0000-7000-8000-000000000003";
|
|
42
|
+
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
|
43
|
+
|
|
44
|
+
function magnet(id: string, ws: string, slug: string, status: string, extra: Row = {}): Row {
|
|
45
|
+
return { id, siteId: ws, workspaceId: ws, slug, title: `Magnet ${slug}`, status, ...extra };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lead(ws: string, n: number, source: string, email: string, ago: number): Row {
|
|
49
|
+
return {
|
|
50
|
+
id: `lead_00000000-0000-7000-8000-${String(n).padStart(12, "0")}`,
|
|
51
|
+
siteId: ws,
|
|
52
|
+
workspaceId: ws,
|
|
53
|
+
source,
|
|
54
|
+
email,
|
|
55
|
+
createdAt: new Date(NOW.getTime() - ago),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
const db = fake.__db;
|
|
61
|
+
for (const key of Object.keys(db)) delete db[key];
|
|
62
|
+
db.flowDefinition = [
|
|
63
|
+
magnet(M1, WS_A1, "contact", "live", { completion: "invite", checkoutOffering: null }),
|
|
64
|
+
magnet(M2, WS_A2, "contact", "live", { completion: "lead", checkoutOffering: null }),
|
|
65
|
+
magnet(DRAFT, WS_A1, "request-quote", "draft", { completion: "invite", checkoutOffering: null }),
|
|
66
|
+
];
|
|
67
|
+
db.lead = [];
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("completionsFor (D3)", () => {
|
|
71
|
+
it("reads only the given workspace's rows; an id absent from it reads as lead", async () => {
|
|
72
|
+
const settings = await completionsFor(WS_A1, [M1, M2]);
|
|
73
|
+
expect(completionOf(settings, M1)).toEqual({ completion: "invite", checkoutOffering: null });
|
|
74
|
+
expect(settings.has(M2)).toBe(false);
|
|
75
|
+
expect(completionOf(settings, M2)).toEqual({ completion: "lead", checkoutOffering: null });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("reads a row with neither column as lead", async () => {
|
|
79
|
+
fake.__db.flowDefinition!.push(
|
|
80
|
+
magnet("flow_00000000-0000-7000-8000-000000000009", WS_A1, "bare", "live"),
|
|
81
|
+
);
|
|
82
|
+
const settings = await completionsFor(WS_A1, ["flow_00000000-0000-7000-8000-000000000009"]);
|
|
83
|
+
expect(completionOf(settings, "flow_00000000-0000-7000-8000-000000000009")).toEqual({
|
|
84
|
+
completion: "lead",
|
|
85
|
+
checkoutOffering: null,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("setCompletion (D3, AC-3)", () => {
|
|
91
|
+
it("writes exactly the pair onto the workspace's own magnet", async () => {
|
|
92
|
+
expect(
|
|
93
|
+
await setCompletion(WS_A1, M1, { completion: "checkout", checkoutOffering: "retainer" }),
|
|
94
|
+
).toBe("set");
|
|
95
|
+
const row = fake.__db.flowDefinition!.find((r) => r.id === M1)!;
|
|
96
|
+
// `updatedAt` is Prisma-managed (`@updatedAt`); every other column is as it was.
|
|
97
|
+
expect(row).toEqual({
|
|
98
|
+
...magnet(M1, WS_A1, "contact", "live", {
|
|
99
|
+
completion: "checkout",
|
|
100
|
+
checkoutOffering: "retainer",
|
|
101
|
+
}),
|
|
102
|
+
updatedAt: expect.any(Date),
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("answers not_found for another workspace's magnet and changes nothing", async () => {
|
|
107
|
+
const before = structuredClone(fake.__db.flowDefinition);
|
|
108
|
+
expect(await setCompletion(WS_A1, M2, { completion: "invite", checkoutOffering: null })).toBe(
|
|
109
|
+
"not_found",
|
|
110
|
+
);
|
|
111
|
+
expect(fake.__db.flowDefinition).toEqual(before);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("liveCompletionBySlug (D3, AC-3)", () => {
|
|
116
|
+
it("answers a live magnet's setting and title", async () => {
|
|
117
|
+
expect(await liveCompletionBySlug(WS_A1, "contact")).toEqual({
|
|
118
|
+
completion: "invite",
|
|
119
|
+
checkoutOffering: null,
|
|
120
|
+
title: "Magnet contact",
|
|
121
|
+
config: undefined,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("answers null for a draft row, an absent slug and another workspace's magnet", async () => {
|
|
126
|
+
expect(await liveCompletionBySlug(WS_A1, "request-quote")).toBeNull();
|
|
127
|
+
expect(await liveCompletionBySlug(WS_A1, "missing")).toBeNull();
|
|
128
|
+
fake.__db.flowDefinition = fake.__db.flowDefinition!.filter((r) => r.id !== M1);
|
|
129
|
+
expect(await liveCompletionBySlug(WS_A1, "contact")).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("inviteSendCounts (D6)", () => {
|
|
134
|
+
it("counts this magnet's leads of this workspace: the recipient's over a day, all over an hour", async () => {
|
|
135
|
+
const HOUR = 60 * 60 * 1000;
|
|
136
|
+
fake.__db.lead = [
|
|
137
|
+
lead(WS_A1, 1, "flow:contact", "a@b.example", 5 * 60 * 1000),
|
|
138
|
+
lead(WS_A1, 2, "flow:contact", "a@b.example", 20 * HOUR),
|
|
139
|
+
lead(WS_A1, 3, "flow:contact", "a@b.example", 25 * HOUR),
|
|
140
|
+
lead(WS_A1, 4, "flow:contact", "c@d.example", 10 * 60 * 1000),
|
|
141
|
+
lead(WS_A1, 5, "flow:other", "a@b.example", 60 * 1000),
|
|
142
|
+
lead(WS_A2, 6, "flow:contact", "a@b.example", 60 * 1000),
|
|
143
|
+
];
|
|
144
|
+
expect(await inviteSendCounts(WS_A1, "contact", "a@b.example", NOW)).toEqual({
|
|
145
|
+
recipientDay: 2,
|
|
146
|
+
siteHour: 2,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file flow-rename-race.db.test.ts
|
|
3
|
+
*
|
|
4
|
+
* LIVE-DATABASE proof of #8734 AC-5 (spec
|
|
5
|
+
* docs/specs/8734-magnet-slug-renames-an-anonymous-spec.md D4): two renames
|
|
6
|
+
* racing for one free slug are serialized by `flow_definitions`'
|
|
7
|
+
* `(site_id, slug)` unique key. `flows.rename`'s first write is the
|
|
8
|
+
* definition row, so the second transaction's write waits on the first's
|
|
9
|
+
* uncommitted index entry and, once the first commits, fails with `23505` —
|
|
10
|
+
* which Prisma reports as `P2002` and `rename` answers `conflict`. The pre-
|
|
11
|
+
* checks run at READ COMMITTED and guarantee nothing here; the key does.
|
|
12
|
+
*
|
|
13
|
+
* Runs only when `AILK_TEST_LIVE_DATABASE === "1"` (set beside a real
|
|
14
|
+
* `DATABASE_URL` by `.github/workflows/api-postgres-compat.yml`), and uses the
|
|
15
|
+
* `pg` driver directly, the same reason `rate-limit-cross-isolate.db.test.ts`
|
|
16
|
+
* does: the generated Prisma client is ESM-only and cannot load under this
|
|
17
|
+
* package's CJS ts-jest transform.
|
|
18
|
+
*/
|
|
19
|
+
import { Client } from "pg";
|
|
20
|
+
|
|
21
|
+
const describeDb =
|
|
22
|
+
process.env.AILK_TEST_LIVE_DATABASE === "1" ? describe : describe.skip;
|
|
23
|
+
|
|
24
|
+
describeDb("flow_definitions' (site_id, slug) key serializes two renames to one slug (live, #8734 AC-5)", () => {
|
|
25
|
+
let first: Client;
|
|
26
|
+
let second: Client;
|
|
27
|
+
let workspaceId = "";
|
|
28
|
+
let rowA = "";
|
|
29
|
+
let rowB = "";
|
|
30
|
+
|
|
31
|
+
async function insertRow(slug: string): Promise<string> {
|
|
32
|
+
const res = await first.query<{ id: string }>(
|
|
33
|
+
`INSERT INTO "flow_definitions"
|
|
34
|
+
("site_id", "workspace_id", "slug", "config", "kind", "title", "status", "customers_enabled", "updated_at")
|
|
35
|
+
VALUES ($1, $1, $2, '{}'::jsonb, 'contact', $2, 'draft', false, now())
|
|
36
|
+
RETURNING "id"`,
|
|
37
|
+
[workspaceId, slug],
|
|
38
|
+
);
|
|
39
|
+
return res.rows[0]!.id;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Until `pid`'s statement is blocked on a lock — the wait the key imposes. */
|
|
43
|
+
async function waitUntilBlocked(pid: number): Promise<void> {
|
|
44
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
45
|
+
const res = await first.query<{ wait_event_type: string | null }>(
|
|
46
|
+
`SELECT "wait_event_type" FROM pg_stat_activity WHERE "pid" = $1`,
|
|
47
|
+
[pid],
|
|
48
|
+
);
|
|
49
|
+
if (res.rows[0]?.wait_event_type === "Lock") return;
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
51
|
+
}
|
|
52
|
+
throw new Error("the second rename never waited on the first");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
first = new Client({ connectionString: process.env.DATABASE_URL });
|
|
57
|
+
second = new Client({ connectionString: process.env.DATABASE_URL });
|
|
58
|
+
await first.connect();
|
|
59
|
+
await second.connect();
|
|
60
|
+
const ws = await first.query<{ id: string }>(
|
|
61
|
+
`SELECT 'wksp_' || uuidv7()::text AS "id"`,
|
|
62
|
+
);
|
|
63
|
+
workspaceId = ws.rows[0]!.id;
|
|
64
|
+
rowA = await insertRow("race-a");
|
|
65
|
+
rowB = await insertRow("race-b");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await first.query(`DELETE FROM "flow_definitions" WHERE "workspace_id" = $1`, [
|
|
70
|
+
workspaceId,
|
|
71
|
+
]);
|
|
72
|
+
await first.end();
|
|
73
|
+
await second.end();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("the second rename to one slug waits on the first, then fails 23505 on flow_definitions_site_id_slug_key; one row holds the slug", async () => {
|
|
77
|
+
const rename = `UPDATE "flow_definitions" SET "slug" = 'race-x' WHERE "id" = $1`;
|
|
78
|
+
const secondPid = (await second.query<{ pid: number }>("SELECT pg_backend_pid() AS pid"))
|
|
79
|
+
.rows[0]!.pid;
|
|
80
|
+
|
|
81
|
+
let error: { code?: string; constraint?: string } | null | undefined;
|
|
82
|
+
try {
|
|
83
|
+
await first.query("BEGIN");
|
|
84
|
+
await first.query(rename, [rowA]);
|
|
85
|
+
|
|
86
|
+
await second.query("BEGIN");
|
|
87
|
+
// Not awaited: it blocks on the first transaction's uncommitted entry.
|
|
88
|
+
const loser = second.query(rename, [rowB]).then(
|
|
89
|
+
() => null,
|
|
90
|
+
(failure: unknown) => failure as { code?: string; constraint?: string },
|
|
91
|
+
);
|
|
92
|
+
await waitUntilBlocked(secondPid);
|
|
93
|
+
|
|
94
|
+
await first.query("COMMIT");
|
|
95
|
+
error = await loser;
|
|
96
|
+
} finally {
|
|
97
|
+
await first.query("ROLLBACK");
|
|
98
|
+
await second.query("ROLLBACK");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
expect(error).toMatchObject({
|
|
102
|
+
code: "23505",
|
|
103
|
+
constraint: "flow_definitions_site_id_slug_key",
|
|
104
|
+
});
|
|
105
|
+
const atSlug = await first.query<{ count: string }>(
|
|
106
|
+
`SELECT count(*) AS "count" FROM "flow_definitions" WHERE "workspace_id" = $1 AND "slug" = 'race-x'`,
|
|
107
|
+
[workspaceId],
|
|
108
|
+
);
|
|
109
|
+
expect(atSlug.rows[0]!.count).toBe("1");
|
|
110
|
+
const b = await first.query<{ slug: string }>(
|
|
111
|
+
`SELECT "slug" FROM "flow_definitions" WHERE "id" = $1`,
|
|
112
|
+
[rowB],
|
|
113
|
+
);
|
|
114
|
+
expect(b.rows[0]!.slug).toBe("race-b");
|
|
115
|
+
});
|
|
116
|
+
});
|