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,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file account.test.ts
|
|
3
|
+
* @description The sign-up agreements' evidence route and the account-level
|
|
4
|
+
* reading (#8765 D2, D5; AC-2, AC-3 and AC-4, route parts).
|
|
5
|
+
*
|
|
6
|
+
* External dependency boundaries mocked:
|
|
7
|
+
* - @working-theory/database (via moduleNameMapper -> prisma-client.js mock;
|
|
8
|
+
* prisma singleton via __mocks__/prisma.ts), driven by the in-memory
|
|
9
|
+
* ledger double
|
|
10
|
+
* - `resolveSiteWorkspaceId` — the seam to the deployment's site workspace
|
|
11
|
+
*
|
|
12
|
+
* The real `services/consent.ts` writer and `lib/consent-store.ts` reader run
|
|
13
|
+
* over the ledger double. The auth preHandler is replaced by a hook that sets
|
|
14
|
+
* the fields it sets (`userId`, `sessionTenant`, `hubSession`), so user `u1`'s
|
|
15
|
+
* cookie session, spoke `S1`'s hub credential for `u1` and a personal token
|
|
16
|
+
* are each one line.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// 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.
|
|
20
|
+
jest.mock("../../../lib/prisma.js");
|
|
21
|
+
|
|
22
|
+
const mockResolveSiteWorkspaceId = jest.fn();
|
|
23
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
24
|
+
jest.mock("../../../lib/site.js", () => ({
|
|
25
|
+
resolveSiteWorkspaceId: () => mockResolveSiteWorkspaceId(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
ACCOUNT_TERMS_STATEMENT,
|
|
32
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
33
|
+
siteConsentConfigSchema,
|
|
34
|
+
} from "@working-theory/validation";
|
|
35
|
+
import Fastify from "fastify";
|
|
36
|
+
import type { FastifyInstance } from "fastify";
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
armConsentLedger,
|
|
40
|
+
type ConsentLedgerDouble,
|
|
41
|
+
} from "../../../lib/__tests__/consent-ledger-double.js";
|
|
42
|
+
import { prisma } from "../../../lib/prisma.js";
|
|
43
|
+
import * as consentService from "../../../services/consent.js";
|
|
44
|
+
import { consentAccountRoutes, consentSignUpRoute } from "../index.js";
|
|
45
|
+
|
|
46
|
+
/** The deployment's own site workspace. */
|
|
47
|
+
const W = "wksp_01920000-0000-7000-8000-0000000000a1";
|
|
48
|
+
/** Spoke S1's own workspace — never where an account-level row is written. */
|
|
49
|
+
const S1_WS = "wksp_01920000-0000-7000-8000-0000000000b1";
|
|
50
|
+
const S1 = "spoke_01920000-0000-7000-8000-0000000000b1";
|
|
51
|
+
const U1 = "user_01920000-0000-7000-8000-0000000000a1";
|
|
52
|
+
|
|
53
|
+
const sha256 = (text: string) =>
|
|
54
|
+
createHash("sha256").update(text).digest("hex");
|
|
55
|
+
|
|
56
|
+
type Caller = "cookie" | "hub" | "token" | "none";
|
|
57
|
+
|
|
58
|
+
let double: ConsentLedgerDouble;
|
|
59
|
+
let app: FastifyInstance;
|
|
60
|
+
|
|
61
|
+
async function build(caller: Caller): Promise<FastifyInstance> {
|
|
62
|
+
const instance = Fastify();
|
|
63
|
+
instance.decorateRequest("userId", undefined);
|
|
64
|
+
instance.decorateRequest("sessionTenant", undefined);
|
|
65
|
+
instance.decorateRequest("hubSession", undefined);
|
|
66
|
+
instance.addHook("onRequest", async (request) => {
|
|
67
|
+
// eslint-disable-next-line no-restricted-syntax -- the test stands in for the auth preHandler, which assigns decorated fields the bare Fastify type does not declare
|
|
68
|
+
const c = request as unknown as Record<string, unknown>;
|
|
69
|
+
if (caller === "none") return;
|
|
70
|
+
c["userId"] = U1;
|
|
71
|
+
if (caller === "cookie") c["sessionTenant"] = { sessionRowId: "s" };
|
|
72
|
+
if (caller === "hub") c["hubSession"] = { spokeId: S1, workspaceId: S1_WS };
|
|
73
|
+
});
|
|
74
|
+
await instance.register(consentSignUpRoute);
|
|
75
|
+
await instance.register(consentAccountRoutes);
|
|
76
|
+
await instance.ready();
|
|
77
|
+
return instance;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The registry with `account_terms` moved to `version`, everything else the default. */
|
|
81
|
+
function registryAt(version: number) {
|
|
82
|
+
return siteConsentConfigSchema.parse({
|
|
83
|
+
...SITE_CONSENT_CONFIG_DEFAULTS,
|
|
84
|
+
purposes: SITE_CONSENT_CONFIG_DEFAULTS.purposes.map((p) =>
|
|
85
|
+
p.id === "account_terms" ? { ...p, policyVersion: version } : p,
|
|
86
|
+
),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const getAccount = (query = "") =>
|
|
91
|
+
app.inject({ method: "GET", url: `/v1/consent/account${query}` });
|
|
92
|
+
const postAccount = (payload: unknown) =>
|
|
93
|
+
app.inject({
|
|
94
|
+
method: "POST",
|
|
95
|
+
url: "/v1/consent/account",
|
|
96
|
+
payload: payload as object,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
jest.restoreAllMocks();
|
|
101
|
+
jest.clearAllMocks();
|
|
102
|
+
double = armConsentLedger(prisma);
|
|
103
|
+
mockResolveSiteWorkspaceId.mockResolvedValue(W);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterEach(async () => {
|
|
107
|
+
await app?.close();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("GET /v1/consent/sign-up (D2)", () => {
|
|
111
|
+
const get = (query = "") =>
|
|
112
|
+
app.inject({ method: "GET", url: `/v1/consent/sign-up${query}` });
|
|
113
|
+
|
|
114
|
+
beforeEach(async () => {
|
|
115
|
+
app = await build("none");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("answers anonymously with W and one account_terms entry at version 2, its statement and its sha256", async () => {
|
|
119
|
+
const res = await get();
|
|
120
|
+
expect(res.statusCode).toBe(200);
|
|
121
|
+
expect(res.headers["cache-control"]).toBe("no-store");
|
|
122
|
+
expect(res.json()).toEqual({
|
|
123
|
+
ok: true,
|
|
124
|
+
data: {
|
|
125
|
+
workspaceId: W,
|
|
126
|
+
agreements: [
|
|
127
|
+
{
|
|
128
|
+
purposeId: "account_terms",
|
|
129
|
+
policyVersion: 2,
|
|
130
|
+
locale: "en",
|
|
131
|
+
statement: ACCOUNT_TERMS_STATEMENT,
|
|
132
|
+
statementHash: sha256(ACCOUNT_TERMS_STATEMENT),
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("reads the default locale en, and records the locale the statement was read in", async () => {
|
|
140
|
+
const plain = (await get()).json().data.agreements[0];
|
|
141
|
+
const fr = (await get("?locale=fr")).json().data.agreements[0];
|
|
142
|
+
expect(plain.locale).toBe("en");
|
|
143
|
+
// The registry declares only English, so a French reader read English.
|
|
144
|
+
expect(fr).toEqual(plain);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("is the evidence the writer stamps: the hash of the statement a grant records", async () => {
|
|
148
|
+
const evidence = (await get()).json().data.agreements[0];
|
|
149
|
+
await app.close();
|
|
150
|
+
app = await build("cookie");
|
|
151
|
+
await postAccount({ purposeIds: ["account_terms"] });
|
|
152
|
+
expect(double.ledger[0]?.statement).toBe(evidence.statement);
|
|
153
|
+
expect(
|
|
154
|
+
consentService.consentStatementHash(double.ledger[0]!.statement),
|
|
155
|
+
).toBe(evidence.statementHash);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("refuses a malformed query with 400 invalid_input", async () => {
|
|
159
|
+
for (const query of ["?locale=not_a_locale", "?workspaceId=x"]) {
|
|
160
|
+
const res = await get(query);
|
|
161
|
+
expect(res.statusCode).toBe(400);
|
|
162
|
+
expect(res.json().error.code).toBe("invalid_input");
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("answers 503 tenant_unresolved, uncached, when the site workspace does not resolve", async () => {
|
|
167
|
+
mockResolveSiteWorkspaceId.mockResolvedValue(null);
|
|
168
|
+
const res = await get();
|
|
169
|
+
expect(res.statusCode).toBe(503);
|
|
170
|
+
expect(res.json().error.code).toBe("tenant_unresolved");
|
|
171
|
+
expect(res.headers["cache-control"]).toBe("no-store");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("GET/POST /v1/consent/account (D5) — AC-2", () => {
|
|
176
|
+
it("asks a user with no user rows: complete false, one entry never accepted", async () => {
|
|
177
|
+
app = await build("cookie");
|
|
178
|
+
const res = await getAccount();
|
|
179
|
+
expect(res.statusCode).toBe(200);
|
|
180
|
+
expect(res.json().data).toEqual({
|
|
181
|
+
complete: false,
|
|
182
|
+
agreements: [
|
|
183
|
+
{
|
|
184
|
+
id: "account_terms",
|
|
185
|
+
statement: ACCOUNT_TERMS_STATEMENT,
|
|
186
|
+
policyVersion: 2,
|
|
187
|
+
acceptedVersion: null,
|
|
188
|
+
acceptedAt: null,
|
|
189
|
+
current: false,
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("POST writes one dated user row in W and answers complete; a second POST adds nothing", async () => {
|
|
196
|
+
app = await build("cookie");
|
|
197
|
+
const res = await postAccount({ purposeIds: ["account_terms"] });
|
|
198
|
+
expect(res.statusCode).toBe(200);
|
|
199
|
+
expect(res.json().data.complete).toBe(true);
|
|
200
|
+
expect(double.ledger).toHaveLength(1);
|
|
201
|
+
expect(double.ledger[0]).toMatchObject({
|
|
202
|
+
workspaceId: W,
|
|
203
|
+
subjectType: "user",
|
|
204
|
+
subjectId: U1,
|
|
205
|
+
personId: null,
|
|
206
|
+
purposeId: "account_terms",
|
|
207
|
+
granted: true,
|
|
208
|
+
source: "account_settings",
|
|
209
|
+
sourceRef: null,
|
|
210
|
+
policyVersion: 2,
|
|
211
|
+
statement: ACCOUNT_TERMS_STATEMENT,
|
|
212
|
+
});
|
|
213
|
+
expect(double.ledger[0]?.createdAt).toBeInstanceOf(Date);
|
|
214
|
+
const [entry] = res.json().data.agreements;
|
|
215
|
+
expect(entry).toMatchObject({
|
|
216
|
+
acceptedVersion: 2,
|
|
217
|
+
acceptedAt: double.ledger[0]!.createdAt.toISOString(),
|
|
218
|
+
current: true,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const again = await postAccount({ purposeIds: ["account_terms"] });
|
|
222
|
+
expect(again.statusCode).toBe(200);
|
|
223
|
+
expect(again.json().data.complete).toBe(true);
|
|
224
|
+
expect(double.ledger).toHaveLength(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("the same POST from S1's credential writes in W, with sourceRef spoke:S1", async () => {
|
|
228
|
+
app = await build("hub");
|
|
229
|
+
const res = await postAccount({ purposeIds: ["account_terms"] });
|
|
230
|
+
expect(res.statusCode).toBe(200);
|
|
231
|
+
expect(double.ledger).toHaveLength(1);
|
|
232
|
+
expect(double.ledger[0]).toMatchObject({
|
|
233
|
+
workspaceId: W,
|
|
234
|
+
subjectType: "user",
|
|
235
|
+
subjectId: U1,
|
|
236
|
+
sourceRef: `spoke:${S1}`,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("refuses a purpose that is not a sign-up agreement with 404 purpose_unknown, writing nothing — even beside a valid one", async () => {
|
|
241
|
+
app = await build("cookie");
|
|
242
|
+
for (const purposeIds of [
|
|
243
|
+
["service_terms"],
|
|
244
|
+
["account_terms", "service_terms"],
|
|
245
|
+
["nope"],
|
|
246
|
+
]) {
|
|
247
|
+
const res = await postAccount({ purposeIds });
|
|
248
|
+
expect(res.statusCode).toBe(404);
|
|
249
|
+
expect(res.json().error.code).toBe("purpose_unknown");
|
|
250
|
+
}
|
|
251
|
+
expect(double.ledger).toHaveLength(0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("refuses a malformed body with 400 invalid_input", async () => {
|
|
255
|
+
app = await build("cookie");
|
|
256
|
+
for (const body of [
|
|
257
|
+
{},
|
|
258
|
+
{ purposeIds: [] },
|
|
259
|
+
{ purposeIds: ["account_terms"], userId: U1 },
|
|
260
|
+
{ purposeIds: ["account_terms"], locale: "not_a_locale" },
|
|
261
|
+
]) {
|
|
262
|
+
const res = await postAccount(body);
|
|
263
|
+
expect(res.statusCode).toBe(400);
|
|
264
|
+
expect(res.json().error.code).toBe("invalid_input");
|
|
265
|
+
}
|
|
266
|
+
expect(double.ledger).toHaveLength(0);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("refuses a personal token and an anonymous caller with 401, before any read", async () => {
|
|
270
|
+
for (const caller of ["token", "none"] as const) {
|
|
271
|
+
app = await build(caller);
|
|
272
|
+
expect((await getAccount()).statusCode).toBe(401);
|
|
273
|
+
expect(
|
|
274
|
+
(await postAccount({ purposeIds: ["account_terms"] })).statusCode,
|
|
275
|
+
).toBe(401);
|
|
276
|
+
await app.close();
|
|
277
|
+
}
|
|
278
|
+
app = await build("cookie");
|
|
279
|
+
expect(prisma.$queryRaw).not.toHaveBeenCalled();
|
|
280
|
+
expect(double.ledger).toHaveLength(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("answers 503 tenant_unresolved when the site workspace does not resolve", async () => {
|
|
284
|
+
mockResolveSiteWorkspaceId.mockResolvedValue(null);
|
|
285
|
+
app = await build("cookie");
|
|
286
|
+
expect((await getAccount()).statusCode).toBe(503);
|
|
287
|
+
const res = await postAccount({ purposeIds: ["account_terms"] });
|
|
288
|
+
expect(res.statusCode).toBe(503);
|
|
289
|
+
expect(res.json().error.code).toBe("tenant_unresolved");
|
|
290
|
+
expect(double.ledger).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("reads a newest row that is not a grant as never accepted", async () => {
|
|
294
|
+
app = await build("cookie");
|
|
295
|
+
await postAccount({ purposeIds: ["account_terms"] });
|
|
296
|
+
double.ledger.push({
|
|
297
|
+
...double.ledger[0]!,
|
|
298
|
+
id: "cnsnt_00000000-0000-7000-8000-0000000000f1",
|
|
299
|
+
granted: false,
|
|
300
|
+
createdAt: new Date(Date.UTC(2026, 9, 13)),
|
|
301
|
+
});
|
|
302
|
+
expect((await getAccount()).json().data.agreements[0]).toMatchObject({
|
|
303
|
+
acceptedVersion: null,
|
|
304
|
+
acceptedAt: null,
|
|
305
|
+
current: false,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("logs { event, outcome } only", async () => {
|
|
310
|
+
app = await build("cookie");
|
|
311
|
+
const lines: unknown[] = [];
|
|
312
|
+
const capture = ((obj: unknown) => {
|
|
313
|
+
lines.push(obj);
|
|
314
|
+
}) as never;
|
|
315
|
+
jest.spyOn(app.log, "info").mockImplementation(capture);
|
|
316
|
+
jest.spyOn(app.log, "warn").mockImplementation(capture);
|
|
317
|
+
await postAccount({ purposeIds: ["account_terms"] });
|
|
318
|
+
await postAccount({ purposeIds: ["service_terms"] });
|
|
319
|
+
const ours = lines.filter(
|
|
320
|
+
(l) => typeof l === "object" && l !== null && "event" in l,
|
|
321
|
+
);
|
|
322
|
+
expect(ours).toEqual([
|
|
323
|
+
{ event: "account-agreements", outcome: "saved" },
|
|
324
|
+
{ event: "account-agreements", outcome: "purpose_unknown" },
|
|
325
|
+
]);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
describe("GET /v1/consent/account — AC-3 (route)", () => {
|
|
330
|
+
it("a grant at version 2 in W reads complete from S1's credential", async () => {
|
|
331
|
+
app = await build("cookie");
|
|
332
|
+
await postAccount({ purposeIds: ["account_terms"] });
|
|
333
|
+
await app.close();
|
|
334
|
+
app = await build("hub");
|
|
335
|
+
const res = await getAccount();
|
|
336
|
+
expect(res.statusCode).toBe(200);
|
|
337
|
+
expect(res.json().data.complete).toBe(true);
|
|
338
|
+
expect(res.json().data.agreements[0]).toMatchObject({
|
|
339
|
+
acceptedVersion: 2,
|
|
340
|
+
current: true,
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe("GET/POST /v1/consent/account — AC-4 (route)", () => {
|
|
346
|
+
it("a bump to version 3 asks again; accepting appends a row and keeps the old one unchanged", async () => {
|
|
347
|
+
app = await build("cookie");
|
|
348
|
+
await postAccount({ purposeIds: ["account_terms"] });
|
|
349
|
+
const before = structuredClone(double.ledger[0]);
|
|
350
|
+
|
|
351
|
+
jest
|
|
352
|
+
.spyOn(consentService, "consentConfigForSite")
|
|
353
|
+
.mockResolvedValue(registryAt(3));
|
|
354
|
+
|
|
355
|
+
const read = (await getAccount()).json().data;
|
|
356
|
+
expect(read.complete).toBe(false);
|
|
357
|
+
expect(read.agreements[0]).toMatchObject({
|
|
358
|
+
policyVersion: 3,
|
|
359
|
+
acceptedVersion: 2,
|
|
360
|
+
current: false,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const res = await postAccount({ purposeIds: ["account_terms"] });
|
|
364
|
+
expect(res.statusCode).toBe(200);
|
|
365
|
+
expect(res.json().data.complete).toBe(true);
|
|
366
|
+
expect(double.ledger).toHaveLength(2);
|
|
367
|
+
expect(double.ledger[0]).toEqual(before);
|
|
368
|
+
expect(double.ledger[1]).toMatchObject({
|
|
369
|
+
subjectType: "user",
|
|
370
|
+
subjectId: U1,
|
|
371
|
+
policyVersion: 3,
|
|
372
|
+
granted: true,
|
|
373
|
+
});
|
|
374
|
+
// The append-only double offers no way to change a stored row: the
|
|
375
|
+
// ledger delegate is append and read only, so an update would throw.
|
|
376
|
+
for (const method of [
|
|
377
|
+
"update",
|
|
378
|
+
"updateMany",
|
|
379
|
+
"upsert",
|
|
380
|
+
"delete",
|
|
381
|
+
"deleteMany",
|
|
382
|
+
]) {
|
|
383
|
+
expect(method in prisma.consentGrant).toBe(false);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file sign-up-rate-limit.test.ts
|
|
3
|
+
* @description #8805 — the sign-up agreements' evidence read is counted per
|
|
4
|
+
* site key, end to end: the real `build()`, the `signupevidence` scope's own
|
|
5
|
+
* limiter and the REAL `@fastify/rate-limit` global ceiling.
|
|
6
|
+
*
|
|
7
|
+
* Every sign-up reads `GET /v1/consent/sign-up` from the web's server, and
|
|
8
|
+
* every web instance behind one egress address used to share one per-IP
|
|
9
|
+
* bucket of 10 a minute — which an anonymous caller spoofing that address's
|
|
10
|
+
* `X-Forwarded-For` could fill. A request presenting a VALID site key (the
|
|
11
|
+
* `x-ailk-spoke-key` the web's api-client already sends) is now counted
|
|
12
|
+
* against that key's own bucket instead, in both limiters; an invalid or
|
|
13
|
+
* absent key keeps the per-IP 10.
|
|
14
|
+
*
|
|
15
|
+
* External dependency boundary mocked: `resolveCallerSite`, the registry
|
|
16
|
+
* lookup a key digests to (one key valid, every other refused), and
|
|
17
|
+
* `resolveSiteWorkspaceId`, the route's own workspace seam. `DATABASE_URL` is
|
|
18
|
+
* unset for the file's duration, so the limiter counts in the memory store.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const mockResolveCallerSite = jest.fn();
|
|
22
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; the registry lookup is the database boundary
|
|
23
|
+
jest.mock("../../../services/public-site-resolver.js", () => ({
|
|
24
|
+
...jest.requireActual<object>("../../../services/public-site-resolver.js"),
|
|
25
|
+
resolveCallerSite: (...args: unknown[]) => mockResolveCallerSite(...args),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const mockResolveSiteWorkspaceId = jest.fn();
|
|
29
|
+
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
30
|
+
jest.mock("../../../lib/site.js", () => ({
|
|
31
|
+
...jest.requireActual<object>("../../../lib/site.js"),
|
|
32
|
+
resolveSiteWorkspaceId: () => mockResolveSiteWorkspaceId(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
jest.mock("@fastify/cors", () => {
|
|
36
|
+
return jest.fn().mockResolvedValue(undefined);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
jest.mock("@working-theory/email", () => ({
|
|
40
|
+
sendLeadNotification: jest.fn().mockResolvedValue({ ok: true }),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import type { FastifyRequest } from "fastify";
|
|
44
|
+
|
|
45
|
+
import { __resetBuckets } from "../../../middleware/rate-limit";
|
|
46
|
+
import { build } from "../../../server";
|
|
47
|
+
|
|
48
|
+
/** The deployment's own site workspace. */
|
|
49
|
+
const W = "wksp_01920000-0000-7000-8000-0000000000a1";
|
|
50
|
+
/** The spoke's own workspace — never the evidence route's answer. */
|
|
51
|
+
const SPOKE_WS = "wksp_01920000-0000-7000-8000-0000000000b1";
|
|
52
|
+
const SPOKE_ID = "spoke_01920000-0000-7000-8000-0000000000b1";
|
|
53
|
+
const VALID = "ailk_spk_valid-site-key";
|
|
54
|
+
const WEB_EGRESS = "198.51.100.20";
|
|
55
|
+
|
|
56
|
+
describe("GET /v1/consent/sign-up against the real limiters (#8805)", () => {
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- server is typed via Fastify generics; explicit any matches server.test.ts
|
|
58
|
+
let app: any;
|
|
59
|
+
const prior = {
|
|
60
|
+
databaseUrl: process.env.DATABASE_URL,
|
|
61
|
+
perKey: process.env.AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
async function statuses(
|
|
65
|
+
count: number,
|
|
66
|
+
headers: Record<string, string>,
|
|
67
|
+
): Promise<number[]> {
|
|
68
|
+
const codes: number[] = [];
|
|
69
|
+
for (let i = 0; i < count; i++) {
|
|
70
|
+
const res = await app.inject({
|
|
71
|
+
method: "GET",
|
|
72
|
+
url: "/v1/consent/sign-up",
|
|
73
|
+
headers,
|
|
74
|
+
});
|
|
75
|
+
codes.push(res.statusCode);
|
|
76
|
+
}
|
|
77
|
+
return codes;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const keyed = (key: string = VALID) => ({
|
|
81
|
+
"x-forwarded-for": WEB_EGRESS,
|
|
82
|
+
"x-ailk-spoke-key": key,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
__resetBuckets();
|
|
87
|
+
delete process.env.DATABASE_URL;
|
|
88
|
+
delete process.env.AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE;
|
|
89
|
+
mockResolveSiteWorkspaceId.mockResolvedValue(W);
|
|
90
|
+
mockResolveCallerSite.mockImplementation((req: FastifyRequest) =>
|
|
91
|
+
Promise.resolve(
|
|
92
|
+
req.headers["x-ailk-spoke-key"] === VALID
|
|
93
|
+
? { kind: "spoke", spokeId: SPOKE_ID, workspaceId: SPOKE_WS }
|
|
94
|
+
: { kind: "refused", reason: "key-unknown" },
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
app = await build();
|
|
98
|
+
await app.ready();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(async () => {
|
|
102
|
+
if (app) await app.close();
|
|
103
|
+
app = undefined;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(() => {
|
|
107
|
+
for (const [name, value] of [
|
|
108
|
+
["DATABASE_URL", prior.databaseUrl],
|
|
109
|
+
["AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE", prior.perKey],
|
|
110
|
+
] as const) {
|
|
111
|
+
if (value === undefined) delete process.env[name];
|
|
112
|
+
else process.env[name] = value;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("admits keyed requests from one IP past 10 a minute — and past the global 60 — up to the key ceiling of 120", async () => {
|
|
117
|
+
const codes = await statuses(121, keyed());
|
|
118
|
+
expect(codes.slice(0, 120).every((code) => code !== 429)).toBe(true);
|
|
119
|
+
expect(codes[120]).toBe(429);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("refuses the eleventh keyless request from one IP with 429", async () => {
|
|
123
|
+
const codes = await statuses(11, { "x-forwarded-for": WEB_EGRESS });
|
|
124
|
+
expect(codes.slice(0, 10).every((code) => code !== 429)).toBe(true);
|
|
125
|
+
expect(codes[10]).toBe(429);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("refuses the eleventh request with an invalid key from one IP with 429", async () => {
|
|
129
|
+
const codes = await statuses(11, keyed("ailk_spk_forged"));
|
|
130
|
+
expect(codes.slice(0, 10).every((code) => code !== 429)).toBe(true);
|
|
131
|
+
expect(codes[10]).toBe(429);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("a spoofer filling the web egress address's buckets leaves the keyed caller its budget", async () => {
|
|
135
|
+
const spoofed = await statuses(70, { "x-forwarded-for": WEB_EGRESS });
|
|
136
|
+
expect(spoofed[69]).toBe(429);
|
|
137
|
+
const codes = await statuses(20, keyed());
|
|
138
|
+
expect(codes.every((code) => code !== 429)).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("reads AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE over the default", async () => {
|
|
142
|
+
await app.close();
|
|
143
|
+
process.env.AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE = "15";
|
|
144
|
+
app = await build();
|
|
145
|
+
await app.ready();
|
|
146
|
+
const codes = await statuses(16, keyed());
|
|
147
|
+
expect(codes.slice(0, 15).every((code) => code !== 429)).toBe(true);
|
|
148
|
+
expect(codes[15]).toBe(429);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("answers a keyed request with the site workspace, never the key's", async () => {
|
|
152
|
+
const res = await app.inject({
|
|
153
|
+
method: "GET",
|
|
154
|
+
url: "/v1/consent/sign-up",
|
|
155
|
+
headers: keyed(),
|
|
156
|
+
});
|
|
157
|
+
expect(res.statusCode).toBe(200);
|
|
158
|
+
expect(res.json().data.workspaceId).toBe(W);
|
|
159
|
+
});
|
|
160
|
+
});
|