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,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file checkout-plan.test.ts
|
|
3
|
+
* @description The hub's checkout plan rule end to end (#8793, spec
|
|
4
|
+
* docs/specs/8793-the-hub-s-checkout-plan-spec.md D1–D3; AC-3, AC-4, AC-5):
|
|
5
|
+
* a spoke's marked checkout lead never writes a Person's plan — it holds it —
|
|
6
|
+
* and the held plan is released only when `resolveMemberPersonId` first links
|
|
7
|
+
* the Person to the signed-in user whose VERIFIED email is the Person's own.
|
|
8
|
+
* A checkout that types another Person's email cannot change that Person's
|
|
9
|
+
* plan, and the lead, the purchase message, the purchase email and the
|
|
10
|
+
* consent grant are written exactly as before.
|
|
11
|
+
*
|
|
12
|
+
* Runs `routeLead` and `resolveMemberPersonId` with the real `person.ts`
|
|
13
|
+
* over `createInMemoryPrisma()`, which enforces the Person's
|
|
14
|
+
* `(workspaceId, email)` and `(workspaceId, userId)` keys.
|
|
15
|
+
*
|
|
16
|
+
* External dependency boundaries mocked:
|
|
17
|
+
* - apps/api/src/lib/prisma — the in-memory, where-clause-honouring double.
|
|
18
|
+
* Its `lead.count` is arranged per test: the purchase email's cap reads
|
|
19
|
+
* with `mode: "insensitive"`, a filter the double does not model.
|
|
20
|
+
* - @working-theory/email — the operator notification and purchase email.
|
|
21
|
+
* - ../plan-offerings.js — the workspace's content-adapter read.
|
|
22
|
+
* - consent.recordCheckoutConsent — spied; the ledger's own transaction is
|
|
23
|
+
* `consent.test.ts`'s, and here only its call is the claim.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); the in-memory double is the repo-standard where-honouring test double for it.
|
|
27
|
+
jest.mock("../../lib/prisma.js", () =>
|
|
28
|
+
jest
|
|
29
|
+
.requireActual<typeof import("../../__tests__/factories/in-memory-prisma")>(
|
|
30
|
+
"../../__tests__/factories/in-memory-prisma",
|
|
31
|
+
)
|
|
32
|
+
.createInMemoryPrisma(),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const mockSendLeadNotification = jest.fn();
|
|
36
|
+
const mockSendPurchaseConfirmationEmail = jest.fn();
|
|
37
|
+
jest.mock("@working-theory/email", () => ({
|
|
38
|
+
sendLeadNotification: (...args: unknown[]) =>
|
|
39
|
+
mockSendLeadNotification(...args),
|
|
40
|
+
sendPurchaseConfirmationEmail: (...args: unknown[]) =>
|
|
41
|
+
mockSendPurchaseConfirmationEmail(...args),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const mockListPlanOfferings = jest.fn();
|
|
45
|
+
// eslint-disable-next-line no-restricted-syntax -- plan-offerings.ts is the workspace's content-adapter read (the database adapter, an external boundary), the same seam lead-routing.test.ts mocks.
|
|
46
|
+
jest.mock("../plan-offerings.js", () => ({
|
|
47
|
+
listPlanOfferings: (...args: unknown[]) => mockListPlanOfferings(...args),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
import { logger } from "@working-theory/observability";
|
|
51
|
+
|
|
52
|
+
import { prisma } from "../../lib/prisma.js";
|
|
53
|
+
import * as consent from "../consent";
|
|
54
|
+
import { routeLead } from "../lead-routing";
|
|
55
|
+
import { resolveMemberPersonId } from "../person";
|
|
56
|
+
|
|
57
|
+
type Row = Record<string, unknown>;
|
|
58
|
+
// The in-memory double's own surface (`__db`, `__calls`) is not on the
|
|
59
|
+
// PrismaClient type the import carries, so it is named here once.
|
|
60
|
+
// eslint-disable-next-line no-restricted-syntax -- justified above
|
|
61
|
+
const store = prisma as unknown as {
|
|
62
|
+
__db: Record<string, Row[]>;
|
|
63
|
+
lead: Record<string, jest.Mock>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const WS = "wksp_00000000-0000-7000-8000-000000008793";
|
|
67
|
+
const KEY = "cs_live_h8793Hold"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8793 AC-3), not a credential
|
|
68
|
+
const KEY_2 = "cs_live_h8793Hold2"; // gitleaks:allow -- a second synthetic Checkout Session id shape (#8793 AC-4), not a credential
|
|
69
|
+
const VICTIM = "victim@x.io";
|
|
70
|
+
const U = "user_00000000-0000-7000-8000-000000008793";
|
|
71
|
+
const ENV_KEYS = [
|
|
72
|
+
"DATABASE_URL",
|
|
73
|
+
"NODE_ENV",
|
|
74
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
75
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
76
|
+
"AILK_DELIVERABLE_EMAIL_FROM",
|
|
77
|
+
] as const;
|
|
78
|
+
|
|
79
|
+
const sourceMeta = {
|
|
80
|
+
ip: "127.0.0.1",
|
|
81
|
+
userAgent: "jest",
|
|
82
|
+
receivedAt: new Date().toISOString(),
|
|
83
|
+
viaSpoke: true,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const people = () => (store.__db["person"] ??= []);
|
|
87
|
+
const users = () => (store.__db["user"] ??= []);
|
|
88
|
+
const leads = () => (store.__db["lead"] ??= []);
|
|
89
|
+
const interactions = () => (store.__db["interaction"] ??= []);
|
|
90
|
+
const victim = () => people().find((p) => p["email"] === VICTIM);
|
|
91
|
+
|
|
92
|
+
function checkout(email: string, offering: string, extra: Row = {}) {
|
|
93
|
+
return {
|
|
94
|
+
kind: "submit" as const,
|
|
95
|
+
siteId: WS,
|
|
96
|
+
source: "web",
|
|
97
|
+
email,
|
|
98
|
+
payload: {
|
|
99
|
+
site: "wendi",
|
|
100
|
+
page: "checkout",
|
|
101
|
+
offering,
|
|
102
|
+
amountTotal: 900,
|
|
103
|
+
currency: "usd",
|
|
104
|
+
...extra,
|
|
105
|
+
} as Record<string, unknown>,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A Person of `WS`, optionally linked to a verified user. */
|
|
110
|
+
function seedVictim(row: { userId?: string; planOffering?: string | null }): void {
|
|
111
|
+
people().push({
|
|
112
|
+
id: "person_00000000-0000-7000-8000-000000008793",
|
|
113
|
+
workspaceId: WS,
|
|
114
|
+
email: VICTIM,
|
|
115
|
+
name: null,
|
|
116
|
+
userId: null,
|
|
117
|
+
billingProvider: null,
|
|
118
|
+
billingCustomerId: null,
|
|
119
|
+
planOffering: null,
|
|
120
|
+
contactConfirmedAt: null,
|
|
121
|
+
contactConfirmedBy: null,
|
|
122
|
+
createdAt: new Date(Date.UTC(2026, 0, 1)),
|
|
123
|
+
...row,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const verifiedUser = (id: string, email: string) =>
|
|
128
|
+
users().push({ id, email, emailVerified: true });
|
|
129
|
+
|
|
130
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
131
|
+
let info: jest.SpyInstance;
|
|
132
|
+
let recordGrant: jest.SpyInstance;
|
|
133
|
+
|
|
134
|
+
const planLines = () =>
|
|
135
|
+
info.mock.calls
|
|
136
|
+
.map(([fields]) => fields as Row)
|
|
137
|
+
.filter((fields) => fields?.["event"] === "checkout-plan");
|
|
138
|
+
|
|
139
|
+
/** Let the fired (void) purchase email run to its end. */
|
|
140
|
+
const settle = async () => {
|
|
141
|
+
for (let i = 0; i < 5; i += 1)
|
|
142
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
for (const key of Object.keys(store.__db)) delete store.__db[key];
|
|
147
|
+
saved = {};
|
|
148
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
149
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
150
|
+
delete process.env.NODE_ENV;
|
|
151
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
152
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
153
|
+
process.env.AILK_DELIVERABLE_EMAIL_FROM = "orders@example.com";
|
|
154
|
+
mockSendLeadNotification.mockReset().mockResolvedValue({ ok: true, messageId: "m" });
|
|
155
|
+
mockSendPurchaseConfirmationEmail
|
|
156
|
+
.mockReset()
|
|
157
|
+
.mockResolvedValue({ ok: true, messageId: "m2" });
|
|
158
|
+
mockListPlanOfferings.mockReset().mockResolvedValue([]);
|
|
159
|
+
store.lead["count"]!.mockReset().mockResolvedValue(1);
|
|
160
|
+
info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
|
|
161
|
+
recordGrant = jest
|
|
162
|
+
.spyOn(consent, "recordCheckoutConsent")
|
|
163
|
+
.mockResolvedValue("recorded");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
for (const k of ENV_KEYS) {
|
|
168
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
169
|
+
else process.env[k] = saved[k];
|
|
170
|
+
}
|
|
171
|
+
info.mockRestore();
|
|
172
|
+
recordGrant.mockRestore();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("a checkout that types another Person's email cannot change that Person's plan (#8793 AC-3)", () => {
|
|
176
|
+
it("a linked victim's set plan stays, and the checkout's line is held", async () => {
|
|
177
|
+
verifiedUser(U, VICTIM);
|
|
178
|
+
seedVictim({ userId: U, planOffering: "premium" });
|
|
179
|
+
await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
|
|
180
|
+
idempotencyKey: KEY,
|
|
181
|
+
});
|
|
182
|
+
expect(victim()!["planOffering"]).toBe("premium");
|
|
183
|
+
expect(planLines()).toEqual([
|
|
184
|
+
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("a linked victim's blank plan stays blank, through the victim's next member read", async () => {
|
|
189
|
+
verifiedUser(U, VICTIM);
|
|
190
|
+
seedVictim({ userId: U });
|
|
191
|
+
await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
|
|
192
|
+
idempotencyKey: KEY,
|
|
193
|
+
});
|
|
194
|
+
expect(victim()!["planOffering"]).toBeNull();
|
|
195
|
+
expect(await resolveMemberPersonId(WS, U, VICTIM)).toBe(victim()!["id"]);
|
|
196
|
+
expect(victim()!["planOffering"]).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("a stranger's later purchase cannot decide an unlinked victim's held plan", async () => {
|
|
200
|
+
seedVictim({});
|
|
201
|
+
leads().push({
|
|
202
|
+
id: "lead_00000000-0000-7000-8000-000000000001",
|
|
203
|
+
workspaceId: WS,
|
|
204
|
+
siteId: WS,
|
|
205
|
+
personId: victim()!["id"],
|
|
206
|
+
email: VICTIM,
|
|
207
|
+
source: "web",
|
|
208
|
+
spokeCheckout: true,
|
|
209
|
+
payload: { site: "wendi", page: "checkout", offering: "wendi_premium" },
|
|
210
|
+
createdAt: new Date(Date.UTC(2026, 0, 2)),
|
|
211
|
+
});
|
|
212
|
+
await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
|
|
213
|
+
idempotencyKey: KEY,
|
|
214
|
+
});
|
|
215
|
+
verifiedUser(U, VICTIM);
|
|
216
|
+
const log = { warn: jest.fn() };
|
|
217
|
+
expect(await resolveMemberPersonId(WS, U, VICTIM, log)).toBe(victim()!["id"]);
|
|
218
|
+
expect(victim()!["planOffering"]).toBeNull();
|
|
219
|
+
// The release ran and refused: the purchases disagree.
|
|
220
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
221
|
+
{ event: "checkout-plan-release", outcome: "ambiguous" },
|
|
222
|
+
expect.any(String),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("an unlinked victim's operator-set plan stays through the first link", async () => {
|
|
227
|
+
seedVictim({ planOffering: "premium" });
|
|
228
|
+
await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
|
|
229
|
+
idempotencyKey: KEY,
|
|
230
|
+
});
|
|
231
|
+
verifiedUser(U, VICTIM);
|
|
232
|
+
expect(await resolveMemberPersonId(WS, U, VICTIM)).toBe(victim()!["id"]);
|
|
233
|
+
expect(victim()!["planOffering"]).toBe("premium");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("a real buyer still gets the plan at their verified first link (#8793 AC-4)", () => {
|
|
238
|
+
const BUYER = "new@x.io";
|
|
239
|
+
const buyer = () => people().find((p) => p["email"] === BUYER)!;
|
|
240
|
+
// A Person the capture created carries no `planOffering` key in the double
|
|
241
|
+
// (Postgres reads the unwritten column back as `null`).
|
|
242
|
+
const buyerPlan = () => buyer()["planOffering"] ?? null;
|
|
243
|
+
|
|
244
|
+
it("the checkout holds, and the buyer's first member read releases it", async () => {
|
|
245
|
+
await routeLead(checkout(BUYER, "wendi_retainer"), sourceMeta, {
|
|
246
|
+
idempotencyKey: KEY,
|
|
247
|
+
});
|
|
248
|
+
expect(buyerPlan()).toBeNull();
|
|
249
|
+
expect(planLines()).toEqual([
|
|
250
|
+
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
251
|
+
]);
|
|
252
|
+
verifiedUser(U, BUYER);
|
|
253
|
+
expect(await resolveMemberPersonId(WS, U, BUYER)).toBe(buyer()["id"]);
|
|
254
|
+
expect(buyerPlan()).toBe("retainer");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("a second purchase of the same offering under a new key still releases it", async () => {
|
|
258
|
+
await routeLead(checkout(BUYER, "wendi_retainer"), sourceMeta, {
|
|
259
|
+
idempotencyKey: KEY,
|
|
260
|
+
});
|
|
261
|
+
await routeLead(checkout(BUYER, "wendi_retainer"), sourceMeta, {
|
|
262
|
+
idempotencyKey: KEY_2,
|
|
263
|
+
});
|
|
264
|
+
expect(leads()).toHaveLength(2);
|
|
265
|
+
verifiedUser(U, BUYER);
|
|
266
|
+
expect(await resolveMemberPersonId(WS, U, BUYER)).toBe(buyer()["id"]);
|
|
267
|
+
expect(buyerPlan()).toBe("retainer");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("the lead, the purchase message, the purchase email and the consent grant are unchanged (#8793 AC-5)", () => {
|
|
272
|
+
it.each([
|
|
273
|
+
["linked with a plan", { userId: U, planOffering: "premium" }],
|
|
274
|
+
["linked with no plan", { userId: U }],
|
|
275
|
+
["unlinked", {}],
|
|
276
|
+
] as const)("victim %s", async (_label, victimRow) => {
|
|
277
|
+
verifiedUser(U, VICTIM);
|
|
278
|
+
seedVictim(victimRow);
|
|
279
|
+
const terms = consent.currentAccountTermsStamp()!;
|
|
280
|
+
const result = await routeLead(
|
|
281
|
+
checkout("VICTIM@x.io", "wendi_basic", { termsAccepted: terms.stamp }),
|
|
282
|
+
sourceMeta,
|
|
283
|
+
{ idempotencyKey: KEY },
|
|
284
|
+
);
|
|
285
|
+
await settle();
|
|
286
|
+
const personId = victim()!["id"];
|
|
287
|
+
expect(result).toEqual({
|
|
288
|
+
ok: true,
|
|
289
|
+
id: leads()[0]!["id"],
|
|
290
|
+
personId,
|
|
291
|
+
persisted: "db",
|
|
292
|
+
email: expect.anything(),
|
|
293
|
+
});
|
|
294
|
+
expect(leads()).toHaveLength(1);
|
|
295
|
+
expect(leads()[0]).toMatchObject({ spokeCheckout: true, personId });
|
|
296
|
+
expect(interactions()).toHaveLength(1);
|
|
297
|
+
expect(interactions()[0]).toMatchObject({
|
|
298
|
+
personId,
|
|
299
|
+
channel: "member",
|
|
300
|
+
direction: "outbound",
|
|
301
|
+
subject: "Your purchase",
|
|
302
|
+
createdBy: "system:checkout",
|
|
303
|
+
});
|
|
304
|
+
expect(mockSendPurchaseConfirmationEmail).toHaveBeenCalledTimes(1);
|
|
305
|
+
expect(recordGrant).toHaveBeenCalledTimes(1);
|
|
306
|
+
expect(recordGrant).toHaveBeenCalledWith({
|
|
307
|
+
workspaceId: WS,
|
|
308
|
+
personId,
|
|
309
|
+
sessionId: KEY,
|
|
310
|
+
policyVersion: terms.policyVersion,
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -471,6 +471,55 @@ describe("fulfillDeliverable", () => {
|
|
|
471
471
|
);
|
|
472
472
|
});
|
|
473
473
|
|
|
474
|
+
// ── #8848 D3 — the minted grant id, and a deliverable flow's Asset-only store ──
|
|
475
|
+
|
|
476
|
+
it("resolves { grantId } equal to the created row's id on a successful mint", async () => {
|
|
477
|
+
await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("resolves undefined on the asset_missing path", async () => {
|
|
481
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
|
|
482
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
|
|
483
|
+
(prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue(null);
|
|
484
|
+
|
|
485
|
+
await expect(fulfillDeliverable(baseInput())).resolves.toBeUndefined();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("a gated file snapshot whose key names only a legacy DeliverableAsset mints no grant (SEC-8848-B)", async () => {
|
|
489
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
|
|
490
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
|
|
491
|
+
const configSnapshot = {
|
|
492
|
+
...baseConfigSnapshot(),
|
|
493
|
+
deliverables: { ...deliverablesBlock(), assets: [{ assetKey: "listing-acme-2026-10-12" }] },
|
|
494
|
+
deliverable: { kind: "file", gated: true, title: en("Q3"), geometry: "letter", pages: 3 },
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
await expect(
|
|
498
|
+
fulfillDeliverable(baseInput({ segment: undefined, configSnapshot })),
|
|
499
|
+
).resolves.toBeUndefined();
|
|
500
|
+
|
|
501
|
+
expect(prisma.deliverableAsset.findUnique).not.toHaveBeenCalled();
|
|
502
|
+
expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
|
|
503
|
+
expect(mockLoggerInfo).toHaveBeenCalledWith(
|
|
504
|
+
expect.objectContaining({ outcome: "asset_missing" }),
|
|
505
|
+
expect.any(String),
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("the same legacy-only key on a flow with no deliverable block still mints (NFR1)", async () => {
|
|
510
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
|
|
511
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
|
|
512
|
+
const configSnapshot = {
|
|
513
|
+
...baseConfigSnapshot(),
|
|
514
|
+
deliverables: { ...deliverablesBlock(), assets: [{ assetKey: "listing-acme-2026-10-12" }] },
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
await expect(
|
|
518
|
+
fulfillDeliverable(baseInput({ segment: undefined, configSnapshot })),
|
|
519
|
+
).resolves.toEqual({ grantId: GRANT_ID });
|
|
520
|
+
expect(prisma.deliverableAsset.findUnique).toHaveBeenCalled();
|
|
521
|
+
});
|
|
522
|
+
|
|
474
523
|
it("never reaches a key that exists only in ANOTHER workspace (NFR3) — the lookup is scoped to this flow's own workspaceId alone", async () => {
|
|
475
524
|
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
476
525
|
workspaceId: WORKSPACE_ID,
|
|
@@ -692,7 +741,7 @@ describe("fulfillDeliverable", () => {
|
|
|
692
741
|
error: { kind: "send_failed", message: "Resend down" },
|
|
693
742
|
});
|
|
694
743
|
|
|
695
|
-
await expect(fulfillDeliverable(baseInput())).resolves.
|
|
744
|
+
await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
|
|
696
745
|
expect(mockLoggerInfo).toHaveBeenCalled();
|
|
697
746
|
// AC4: send_failed is a post-mint outcome — it stamps too.
|
|
698
747
|
expect(prisma.deliverableGrant.update).toHaveBeenCalledWith({
|
|
@@ -708,7 +757,7 @@ describe("fulfillDeliverable", () => {
|
|
|
708
757
|
new Error("connection reset"),
|
|
709
758
|
);
|
|
710
759
|
|
|
711
|
-
await expect(fulfillDeliverable(baseInput())).resolves.
|
|
760
|
+
await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
|
|
712
761
|
expect(mockSendDeliverableEmail).toHaveBeenCalled();
|
|
713
762
|
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
|
714
763
|
expect.objectContaining({ grantId: GRANT_ID, outcome: "sent" }),
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file keyless-workspace-predicate.test.ts
|
|
3
|
+
*
|
|
4
|
+
* One keyless-workspace predicate (#9145). `resolvePublicWorkspace` (content,
|
|
5
|
+
* pricing, schedule, the consent cookie) and `resolveCallerWorkspace` (the
|
|
6
|
+
* `/v1/flows` routes and the open deliverable) answer a keyless caller through
|
|
7
|
+
* the SAME rule — `services/default-tenant.ts#resolveDefaultWorkspaceId`: one
|
|
8
|
+
* workspace that is the seeded default resolves to it, anything else is
|
|
9
|
+
* unresolved. The `AILK_SITE_ID` alias is never consulted for a keyless
|
|
10
|
+
* caller, so a multi-workspace deployment that is not a hub answers every
|
|
11
|
+
* anonymous route the same way instead of 503 on `/pricing` and the alias
|
|
12
|
+
* workspace on `/flows/*`.
|
|
13
|
+
*
|
|
14
|
+
* The spoke-key arm and every refusal are unchanged; the hub cases here pin
|
|
15
|
+
* that a keyless call on a hub is refused by both resolvers alike.
|
|
16
|
+
*
|
|
17
|
+
* External dependency boundaries mocked:
|
|
18
|
+
* - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
|
|
22
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
23
|
+
jest.mock("../../lib/prisma.js");
|
|
24
|
+
|
|
25
|
+
import { DEFAULT_WORKSPACE_ID } from "@working-theory/database/tenancy";
|
|
26
|
+
import type { FastifyRequest } from "fastify";
|
|
27
|
+
|
|
28
|
+
import * as configModule from "../../config/index.js";
|
|
29
|
+
import type { HubSpokeKeyMode } from "../../config/index.js";
|
|
30
|
+
import { __resetSiteWorkspaceCacheForTests } from "../../lib/site.js";
|
|
31
|
+
import {
|
|
32
|
+
mockSentinelWorkspaceExists,
|
|
33
|
+
mockWorkspaceExists,
|
|
34
|
+
} from "../../lib/__mocks__/prisma.js";
|
|
35
|
+
import { prisma } from "../../lib/prisma.js";
|
|
36
|
+
import {
|
|
37
|
+
__resetHubStatusForTests,
|
|
38
|
+
resolveCallerWorkspace,
|
|
39
|
+
resolvePublicWorkspace,
|
|
40
|
+
} from "../public-site-resolver.js";
|
|
41
|
+
|
|
42
|
+
const db = jest.mocked(prisma);
|
|
43
|
+
|
|
44
|
+
const W1 = "wksp_01920000-0000-7000-8000-0000000000b1";
|
|
45
|
+
const W2 = "wksp_01920000-0000-7000-8000-0000000000b2";
|
|
46
|
+
const S1 = "spoke_01920000-0000-7000-8000-0000000000c1";
|
|
47
|
+
const KEY_S1 = `ailk_spk_S1${"x".repeat(41)}`;
|
|
48
|
+
|
|
49
|
+
const digest = (key: string) => createHash("sha256").update(key).digest("hex");
|
|
50
|
+
|
|
51
|
+
/** A request double carrying only what the resolver may read. */
|
|
52
|
+
function requestWith(headers: Record<string, string> = {}): FastifyRequest {
|
|
53
|
+
// eslint-disable-next-line no-restricted-syntax -- a FastifyRequest double carrying only the two members the resolver reads (headers, log)
|
|
54
|
+
return {
|
|
55
|
+
headers,
|
|
56
|
+
log: { warn: jest.fn(), info: jest.fn(), error: jest.fn() },
|
|
57
|
+
} as unknown as FastifyRequest;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setMode(mode: HubSpokeKeyMode): void {
|
|
61
|
+
jest.replaceProperty(configModule, "config", {
|
|
62
|
+
...configModule.config,
|
|
63
|
+
AILK_HUB_REQUIRE_SPOKE_KEY: mode,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A deployment with no spoke registered: not a hub (#7633 D1). */
|
|
68
|
+
function notAHub(): void {
|
|
69
|
+
db.spoke.findFirst.mockResolvedValue(null);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A hub: its registry holds `S1` → `W1`. */
|
|
73
|
+
function aHub(): void {
|
|
74
|
+
db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
|
|
75
|
+
db.spoke.findUnique.mockImplementation((async ({
|
|
76
|
+
where,
|
|
77
|
+
}: {
|
|
78
|
+
where: { credentialHash: string };
|
|
79
|
+
}) =>
|
|
80
|
+
where.credentialHash === digest(KEY_S1)
|
|
81
|
+
? { id: S1, workspaceId: W1, revokedAt: null }
|
|
82
|
+
: null) as never);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Exactly one workspace, the seeded default on the sentinel Organization. */
|
|
86
|
+
function oneWorkspaceDeployment(): void {
|
|
87
|
+
db.workspace.count.mockResolvedValue(1);
|
|
88
|
+
mockSentinelWorkspaceExists(db);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Three workspaces — the seeded default beside `W1` and `W2`. */
|
|
92
|
+
function multiWorkspaceDeployment(): void {
|
|
93
|
+
db.workspace.count.mockResolvedValue(3);
|
|
94
|
+
mockSentinelWorkspaceExists(db, W1, W2);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const saved = {
|
|
98
|
+
adapter: process.env.CONTENT_ADAPTER,
|
|
99
|
+
databaseUrl: process.env.DATABASE_URL,
|
|
100
|
+
siteId: process.env.AILK_SITE_ID,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function restore(name: string, value: string | undefined): void {
|
|
104
|
+
if (value === undefined) delete process.env[name];
|
|
105
|
+
else process.env[name] = value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
jest.resetAllMocks();
|
|
110
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
111
|
+
process.env.CONTENT_ADAPTER = "database";
|
|
112
|
+
delete process.env.AILK_SITE_ID;
|
|
113
|
+
setMode("auto");
|
|
114
|
+
__resetHubStatusForTests();
|
|
115
|
+
__resetSiteWorkspaceCacheForTests();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
jest.restoreAllMocks();
|
|
120
|
+
restore("CONTENT_ADAPTER", saved.adapter);
|
|
121
|
+
restore("DATABASE_URL", saved.databaseUrl);
|
|
122
|
+
restore("AILK_SITE_ID", saved.siteId);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("a one-workspace deployment that is not a hub (#9145)", () => {
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
notAHub();
|
|
128
|
+
oneWorkspaceDeployment();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("serves the same workspace through both resolvers", async () => {
|
|
132
|
+
const pub = await resolvePublicWorkspace(requestWith());
|
|
133
|
+
const caller = await resolveCallerWorkspace(requestWith());
|
|
134
|
+
|
|
135
|
+
expect(pub).toEqual({ kind: "resolved", workspaceId: DEFAULT_WORKSPACE_ID });
|
|
136
|
+
expect(caller).toEqual({
|
|
137
|
+
kind: "resolved",
|
|
138
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
139
|
+
viaSpoke: false,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("resolveCallerWorkspace answers the predicate on any content adapter — flows are database rows", async () => {
|
|
144
|
+
process.env.CONTENT_ADAPTER = "mdx";
|
|
145
|
+
|
|
146
|
+
await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
|
|
147
|
+
kind: "resolved",
|
|
148
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
149
|
+
viaSpoke: false,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("a multi-workspace deployment that is not a hub (#9145)", () => {
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
notAHub();
|
|
157
|
+
multiWorkspaceDeployment();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("answers unresolved through both resolvers, though the default alias resolves", async () => {
|
|
161
|
+
const pub = await resolvePublicWorkspace(requestWith());
|
|
162
|
+
const caller = await resolveCallerWorkspace(requestWith());
|
|
163
|
+
|
|
164
|
+
expect(pub).toEqual({ kind: "unresolved", reason: "ambiguous" });
|
|
165
|
+
expect(caller).toEqual({ kind: "unresolved", reason: "ambiguous" });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("answers unresolved through both, though AILK_SITE_ID names a real workspace", async () => {
|
|
169
|
+
process.env.AILK_SITE_ID = W1;
|
|
170
|
+
|
|
171
|
+
await expect(resolvePublicWorkspace(requestWith())).resolves.toEqual({
|
|
172
|
+
kind: "unresolved",
|
|
173
|
+
reason: "ambiguous",
|
|
174
|
+
});
|
|
175
|
+
await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
|
|
176
|
+
kind: "unresolved",
|
|
177
|
+
reason: "ambiguous",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("a sole workspace that is not the seeded default (#9145)", () => {
|
|
183
|
+
it("answers unresolved through both, though AILK_SITE_ID names it", async () => {
|
|
184
|
+
notAHub();
|
|
185
|
+
process.env.AILK_SITE_ID = W1;
|
|
186
|
+
db.workspace.count.mockResolvedValue(1);
|
|
187
|
+
mockWorkspaceExists(db, W1);
|
|
188
|
+
|
|
189
|
+
await expect(resolvePublicWorkspace(requestWith())).resolves.toEqual({
|
|
190
|
+
kind: "unresolved",
|
|
191
|
+
reason: "ambiguous",
|
|
192
|
+
});
|
|
193
|
+
await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
|
|
194
|
+
kind: "unresolved",
|
|
195
|
+
reason: "ambiguous",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("a hub (#9145 — the refusals are unchanged)", () => {
|
|
201
|
+
it("AILK_HUB_REQUIRE_SPOKE_KEY on refuses a keyless call through both, on a one-workspace deployment", async () => {
|
|
202
|
+
setMode("on");
|
|
203
|
+
notAHub();
|
|
204
|
+
oneWorkspaceDeployment();
|
|
205
|
+
|
|
206
|
+
await expect(resolvePublicWorkspace(requestWith())).resolves.toEqual({
|
|
207
|
+
kind: "unresolved",
|
|
208
|
+
reason: "spoke-refused",
|
|
209
|
+
});
|
|
210
|
+
await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
|
|
211
|
+
kind: "unresolved",
|
|
212
|
+
reason: "spoke-refused",
|
|
213
|
+
});
|
|
214
|
+
expect(db.workspace.count).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("a registered spoke still resolves to its own workspace through both", async () => {
|
|
218
|
+
aHub();
|
|
219
|
+
multiWorkspaceDeployment();
|
|
220
|
+
const headers = { "x-ailk-spoke-key": KEY_S1 };
|
|
221
|
+
|
|
222
|
+
await expect(resolvePublicWorkspace(requestWith(headers))).resolves.toEqual({
|
|
223
|
+
kind: "resolved",
|
|
224
|
+
workspaceId: W1,
|
|
225
|
+
});
|
|
226
|
+
await expect(resolveCallerWorkspace(requestWith(headers))).resolves.toEqual({
|
|
227
|
+
kind: "resolved",
|
|
228
|
+
workspaceId: W1,
|
|
229
|
+
viaSpoke: true,
|
|
230
|
+
spokeId: S1,
|
|
231
|
+
});
|
|
232
|
+
expect(db.workspace.count).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
});
|