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
|
@@ -53,7 +53,11 @@ let spokeA: SeededSpoke;
|
|
|
53
53
|
let spokeB: SeededSpoke;
|
|
54
54
|
let handlerRuns: string[];
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* D1's 18 strings plus #8435's two contact routes, #8438's two consents
|
|
58
|
+
* routes, #8765's account-agreements pair and #8515's onboarding pair (26),
|
|
59
|
+
* in the list's order.
|
|
60
|
+
*/
|
|
57
61
|
const ADMITTED = [
|
|
58
62
|
"GET /v1/hub-sign-in/me",
|
|
59
63
|
"GET /v1/member/pieces",
|
|
@@ -77,6 +81,9 @@ const ADMITTED = [
|
|
|
77
81
|
"POST /v1/member/deletion-request",
|
|
78
82
|
"GET /v1/member/consents",
|
|
79
83
|
"POST /v1/member/consents",
|
|
84
|
+
// #8765 D5 — the caller's account-level sign-up agreements.
|
|
85
|
+
"GET /v1/consent/account",
|
|
86
|
+
"POST /v1/consent/account",
|
|
80
87
|
// #8515 D13 — the client's onboarding read and write.
|
|
81
88
|
"GET /v1/member/onboarding",
|
|
82
89
|
"POST /v1/member/onboarding",
|
|
@@ -86,6 +93,8 @@ const ADMITTED = [
|
|
|
86
93
|
const OFF_LIST = [
|
|
87
94
|
"GET /v1/consent/platform",
|
|
88
95
|
"POST /v1/consent/platform",
|
|
96
|
+
// #8765 D2 — the anonymous sign-up evidence is never a credential's route.
|
|
97
|
+
"GET /v1/consent/sign-up",
|
|
89
98
|
"GET /v1/member/other",
|
|
90
99
|
"GET /v1/member/messages/:id",
|
|
91
100
|
"GET /v1/waitlist-signups/me",
|
|
@@ -177,9 +186,9 @@ afterEach(async () => {
|
|
|
177
186
|
});
|
|
178
187
|
|
|
179
188
|
describe("HUB_SESSION_ROUTES", () => {
|
|
180
|
-
it("is exactly the
|
|
189
|
+
it("is exactly the 26 entries, by method and registered URL — never a prefix (AC-1, #8515 AC-6, #8765 AC-6)", () => {
|
|
181
190
|
expect([...HUB_SESSION_ROUTES]).toEqual(ADMITTED);
|
|
182
|
-
expect(HUB_SESSION_ROUTES.length).toBe(
|
|
191
|
+
expect(HUB_SESSION_ROUTES.length).toBe(26);
|
|
183
192
|
for (const route of HUB_SESSION_ROUTES) {
|
|
184
193
|
expect({
|
|
185
194
|
route,
|
|
@@ -222,6 +231,8 @@ describe("hub credential — admitted routes", () => {
|
|
|
222
231
|
|
|
223
232
|
describe("hub credential — every other route is 404 with no handler run (AC-7)", () => {
|
|
224
233
|
it.each([
|
|
234
|
+
["GET", "/v1/consent/sign-up"],
|
|
235
|
+
["GET", "/v1/consent/platform"],
|
|
225
236
|
["GET", "/v1/member/other"],
|
|
226
237
|
["GET", "/v1/member/messages/lead_1"],
|
|
227
238
|
["GET", "/v1/waitlist-signups/me"],
|
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
buildTrustedKeyDigests,
|
|
23
23
|
DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
24
24
|
resolveRateLimitIdentity,
|
|
25
|
+
siteKeyReader,
|
|
26
|
+
SITE_KEYED_ROUTES,
|
|
27
|
+
DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
|
|
25
28
|
type RateLimitOptions,
|
|
26
29
|
} from "../rate-limit.js";
|
|
27
30
|
|
|
@@ -845,6 +848,7 @@ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394
|
|
|
845
848
|
{ method: "POST", url: "/v1/project-listings" },
|
|
846
849
|
{ method: "PATCH", url: "/v1/project-listings/drafts/:id" },
|
|
847
850
|
{ method: "POST", url: "/v1/project-listings/drafts/:id/submit" },
|
|
851
|
+
{ method: "GET", url: "/v1/deliverables/open/:slug" },
|
|
848
852
|
]);
|
|
849
853
|
});
|
|
850
854
|
});
|
|
@@ -1189,3 +1193,217 @@ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394
|
|
|
1189
1193
|
});
|
|
1190
1194
|
});
|
|
1191
1195
|
});
|
|
1196
|
+
|
|
1197
|
+
describe("the sign-up evidence read is counted per site key (#8805)", () => {
|
|
1198
|
+
const SIGN_UP = "/v1/consent/sign-up";
|
|
1199
|
+
const WEB_EGRESS = "198.51.100.20";
|
|
1200
|
+
const VALID = "ailk_spk_valid-site-key";
|
|
1201
|
+
const SPOKE_ID = "spoke_01920000-0000-7000-8000-0000000000b1";
|
|
1202
|
+
const ENV_KEYS = [
|
|
1203
|
+
"AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_MINUTE",
|
|
1204
|
+
"AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE",
|
|
1205
|
+
] as const;
|
|
1206
|
+
const priorEnv = new Map<string, string | undefined>(
|
|
1207
|
+
ENV_KEYS.map((name) => [name, process.env[name]]),
|
|
1208
|
+
);
|
|
1209
|
+
|
|
1210
|
+
let app: FastifyInstance | undefined;
|
|
1211
|
+
let verify: jest.Mock<Promise<string | null>, [unknown]>;
|
|
1212
|
+
let logLines: Array<Record<string, unknown>>;
|
|
1213
|
+
|
|
1214
|
+
function signUpApp(opts: Partial<RateLimitOptions> = {}): FastifyInstance {
|
|
1215
|
+
const instance = Fastify({
|
|
1216
|
+
trustProxy: true,
|
|
1217
|
+
logger: {
|
|
1218
|
+
level: "warn",
|
|
1219
|
+
stream: {
|
|
1220
|
+
write(msg: string) {
|
|
1221
|
+
logLines.push(JSON.parse(msg) as Record<string, unknown>);
|
|
1222
|
+
},
|
|
1223
|
+
},
|
|
1224
|
+
},
|
|
1225
|
+
});
|
|
1226
|
+
instance.register(rateLimitMiddleware, {
|
|
1227
|
+
perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
1228
|
+
perKeyPerMinute: 25,
|
|
1229
|
+
scope: "signupevidence",
|
|
1230
|
+
siteKeyOf: siteKeyReader(verify),
|
|
1231
|
+
...opts,
|
|
1232
|
+
});
|
|
1233
|
+
instance.get(SIGN_UP, async () => ({ ok: true }));
|
|
1234
|
+
instance.get("/v1/other", async () => ({ ok: true }));
|
|
1235
|
+
return instance;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
async function statuses(
|
|
1239
|
+
count: number,
|
|
1240
|
+
headers: Record<string, string>,
|
|
1241
|
+
url: string = SIGN_UP,
|
|
1242
|
+
): Promise<number[]> {
|
|
1243
|
+
if (!app) throw new Error("no app");
|
|
1244
|
+
const codes: number[] = [];
|
|
1245
|
+
for (let i = 0; i < count; i++) {
|
|
1246
|
+
const res = await app.inject({ method: "GET", url, headers });
|
|
1247
|
+
codes.push(res.statusCode);
|
|
1248
|
+
}
|
|
1249
|
+
return codes;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const keyed = (ip: string = WEB_EGRESS, key: string = VALID) => ({
|
|
1253
|
+
"x-forwarded-for": ip,
|
|
1254
|
+
"x-ailk-spoke-key": key,
|
|
1255
|
+
});
|
|
1256
|
+
|
|
1257
|
+
beforeEach(() => {
|
|
1258
|
+
__resetBuckets();
|
|
1259
|
+
logLines = [];
|
|
1260
|
+
for (const name of ENV_KEYS) delete process.env[name];
|
|
1261
|
+
verify = jest.fn((req: unknown) => {
|
|
1262
|
+
const headers = (req as { headers: Record<string, unknown> }).headers;
|
|
1263
|
+
return Promise.resolve(
|
|
1264
|
+
headers["x-ailk-spoke-key"] === VALID ? SPOKE_ID : null,
|
|
1265
|
+
);
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
afterEach(async () => {
|
|
1270
|
+
if (app) await app.close();
|
|
1271
|
+
app = undefined;
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
afterAll(() => {
|
|
1275
|
+
for (const [name, value] of priorEnv) {
|
|
1276
|
+
if (value === undefined) delete process.env[name];
|
|
1277
|
+
else process.env[name] = value;
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it("lists GET /v1/consent/sign-up as the one site-keyed route", () => {
|
|
1282
|
+
expect(SITE_KEYED_ROUTES).toEqual([{ method: "GET", url: SIGN_UP }]);
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
it("names a per-key default well above the per-IP 10", () => {
|
|
1286
|
+
expect(DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE).toBe(120);
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
it("admits keyed requests from one IP past 10 a minute, up to the key ceiling, then 429", async () => {
|
|
1290
|
+
app = signUpApp();
|
|
1291
|
+
await app.ready();
|
|
1292
|
+
const codes = await statuses(26, keyed());
|
|
1293
|
+
expect(codes.slice(0, 25).every((code) => code === 200)).toBe(true);
|
|
1294
|
+
expect(codes[25]).toBe(429);
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it("refuses the eleventh keyless request from one IP with 429", async () => {
|
|
1298
|
+
app = signUpApp();
|
|
1299
|
+
await app.ready();
|
|
1300
|
+
const codes = await statuses(11, { "x-forwarded-for": WEB_EGRESS });
|
|
1301
|
+
expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
|
|
1302
|
+
expect(codes[10]).toBe(429);
|
|
1303
|
+
expect(verify).not.toHaveBeenCalled();
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it("keeps an invalid key on the per-IP 10 a minute", async () => {
|
|
1307
|
+
app = signUpApp();
|
|
1308
|
+
await app.ready();
|
|
1309
|
+
const codes = await statuses(11, keyed(WEB_EGRESS, "ailk_spk_forged"));
|
|
1310
|
+
expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
|
|
1311
|
+
expect(codes[10]).toBe(429);
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
it("an anonymous caller spoofing the web's X-Forwarded-For cannot spend the keyed budget", async () => {
|
|
1315
|
+
app = signUpApp();
|
|
1316
|
+
await app.ready();
|
|
1317
|
+
// The spoofer fills the per-IP bucket of the web's own egress address…
|
|
1318
|
+
const spoofed = await statuses(30, { "x-forwarded-for": WEB_EGRESS });
|
|
1319
|
+
expect(spoofed[10]).toBe(429);
|
|
1320
|
+
// …and with a forged key too.
|
|
1321
|
+
await statuses(30, keyed(WEB_EGRESS, "ailk_spk_forged"));
|
|
1322
|
+
// The keyed caller behind that address still has its whole budget.
|
|
1323
|
+
const codes = await statuses(25, keyed());
|
|
1324
|
+
expect(codes.every((code) => code === 200)).toBe(true);
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
it("many web instances behind one egress share the key bucket, not an IP bucket", async () => {
|
|
1328
|
+
app = signUpApp();
|
|
1329
|
+
await app.ready();
|
|
1330
|
+
const codes: number[] = [];
|
|
1331
|
+
for (let i = 0; i < 26; i++) {
|
|
1332
|
+
codes.push(...(await statuses(1, keyed(`203.0.113.${i + 1}`))));
|
|
1333
|
+
}
|
|
1334
|
+
expect(codes.slice(0, 25).every((code) => code === 200)).toBe(true);
|
|
1335
|
+
expect(codes[25]).toBe(429);
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
it("a verification failure counts the request per IP, never unlimited", async () => {
|
|
1339
|
+
verify.mockRejectedValue(new Error("db down: postgres://secret"));
|
|
1340
|
+
app = signUpApp();
|
|
1341
|
+
await app.ready();
|
|
1342
|
+
const codes = await statuses(11, keyed());
|
|
1343
|
+
expect(codes[10]).toBe(429);
|
|
1344
|
+
const line = logLines.find(
|
|
1345
|
+
(l) => l["event"] === "rate_limit_site_key_unverified",
|
|
1346
|
+
);
|
|
1347
|
+
expect(line).toBeDefined();
|
|
1348
|
+
expect(JSON.stringify(logLines)).not.toContain("postgres://secret");
|
|
1349
|
+
expect(JSON.stringify(logLines)).not.toContain(VALID);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
it("a lookup that outlasts its timeout counts the request per IP", async () => {
|
|
1353
|
+
verify.mockImplementation(() => new Promise<string | null>(() => undefined));
|
|
1354
|
+
app = signUpApp({ siteKeyOf: siteKeyReader(verify, 5) });
|
|
1355
|
+
await app.ready();
|
|
1356
|
+
const codes = await statuses(11, keyed());
|
|
1357
|
+
expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
|
|
1358
|
+
expect(codes[10]).toBe(429);
|
|
1359
|
+
expect(
|
|
1360
|
+
logLines.some((l) => l["event"] === "rate_limit_site_key_unverified"),
|
|
1361
|
+
).toBe(true);
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it("ignores a key on any route but the sign-up evidence read", async () => {
|
|
1365
|
+
app = signUpApp();
|
|
1366
|
+
await app.ready();
|
|
1367
|
+
const codes = await statuses(11, keyed(), "/v1/other");
|
|
1368
|
+
expect(codes[10]).toBe(429);
|
|
1369
|
+
expect(verify).not.toHaveBeenCalled();
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
it("verifies the key once per request, however many limiters read it", async () => {
|
|
1373
|
+
const reader = siteKeyReader(verify);
|
|
1374
|
+
app = signUpApp({ siteKeyOf: reader });
|
|
1375
|
+
// A second reader of the same request, as the server-wide limiter is.
|
|
1376
|
+
app.addHook("onRequest", async (req) => {
|
|
1377
|
+
await reader(req);
|
|
1378
|
+
});
|
|
1379
|
+
await app.ready();
|
|
1380
|
+
await statuses(3, keyed());
|
|
1381
|
+
expect(verify).toHaveBeenCalledTimes(3);
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
it("reads AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE over the code value", async () => {
|
|
1385
|
+
process.env.AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE = "12";
|
|
1386
|
+
app = signUpApp();
|
|
1387
|
+
await app.ready();
|
|
1388
|
+
const codes = await statuses(13, keyed());
|
|
1389
|
+
expect(codes.slice(0, 12).every((code) => code === 200)).toBe(true);
|
|
1390
|
+
expect(codes[12]).toBe(429);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it("logs a keyed 429 with bucket site and the spoke id, never the key", async () => {
|
|
1394
|
+
app = signUpApp({ perKeyPerMinute: 1 });
|
|
1395
|
+
await app.ready();
|
|
1396
|
+
await statuses(2, keyed());
|
|
1397
|
+
const line = logLines.find((l) => l["msg"] === "rate_limited");
|
|
1398
|
+
expect(line).toMatchObject({
|
|
1399
|
+
ip: `site,${SPOKE_ID}`,
|
|
1400
|
+
bucket: "site",
|
|
1401
|
+
});
|
|
1402
|
+
expect(JSON.stringify(logLines)).not.toContain(VALID);
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
it("rejects siteKeyOf without a scope", async () => {
|
|
1406
|
+
app = signUpApp({ scope: undefined });
|
|
1407
|
+
await expect(app.ready()).rejects.toThrow(/scope/);
|
|
1408
|
+
});
|
|
1409
|
+
});
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file write-budget.test.ts
|
|
3
|
+
* @description The Stripe connection write budget (#8907 D9, AC-12; contract
|
|
4
|
+
* K10) — `chargeWriteBudget` in middleware/rate-limit.ts.
|
|
5
|
+
*
|
|
6
|
+
* It counts every write against an hourly per-Organization and per-person
|
|
7
|
+
* bucket in its OWN database store — never the process default — so neither
|
|
8
|
+
* `AILK_RATE_LIMIT_STORE=memory` nor the per-minute sweep of the shared maps
|
|
9
|
+
* can reset an hourly bucket (SEC-8907-2). A store that rejects falls back to
|
|
10
|
+
* a module-private count the sweep never touches, capped at `MAX_BUCKETS`.
|
|
11
|
+
*/
|
|
12
|
+
import Fastify from "fastify";
|
|
13
|
+
|
|
14
|
+
import type { RateLimitBucket, RateLimitStore } from "../../lib/rate-limit-store.js";
|
|
15
|
+
|
|
16
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary; the budget's store is replaced below, so no client is ever built.
|
|
17
|
+
jest.mock("../../lib/prisma.js", () => ({ prisma: {} }));
|
|
18
|
+
|
|
19
|
+
const mockCreateDatabaseStore = jest.fn();
|
|
20
|
+
// eslint-disable-next-line no-restricted-syntax -- the database store's constructor is the seam the case under test is about: the budget must build its own database store whatever AILK_RATE_LIMIT_STORE says
|
|
21
|
+
jest.mock("../../lib/rate-limit-store.js", () => ({
|
|
22
|
+
...jest.requireActual("../../lib/rate-limit-store.js"),
|
|
23
|
+
createDatabaseRateLimitStore: (...args: unknown[]) => mockCreateDatabaseStore(...args),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
__bucketCount,
|
|
28
|
+
__resetBuckets,
|
|
29
|
+
__setWriteBudgetStoreForTests,
|
|
30
|
+
__writeBudgetFallbackCount,
|
|
31
|
+
chargeWriteBudget,
|
|
32
|
+
MAX_BUCKETS,
|
|
33
|
+
rateLimitMiddleware,
|
|
34
|
+
STRIPE_CONNECTION_WRITES_PER_ORG_PER_HOUR,
|
|
35
|
+
STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR,
|
|
36
|
+
} from "../rate-limit.js";
|
|
37
|
+
|
|
38
|
+
const HOUR = 60 * 60 * 1000;
|
|
39
|
+
const T0 = 1_800_000_000_000;
|
|
40
|
+
|
|
41
|
+
/** A `hitKey` that counts per key and window, as the database store's upsert does. */
|
|
42
|
+
function countingStore(): RateLimitStore & { hitKey: jest.Mock; keys: Map<string, RateLimitBucket> } {
|
|
43
|
+
const keys = new Map<string, RateLimitBucket>();
|
|
44
|
+
const hitKey = jest.fn(async (key: string, now: number, windowMs: number) => {
|
|
45
|
+
let bucket = keys.get(key);
|
|
46
|
+
if (!bucket || now - bucket.windowStartMs >= windowMs) {
|
|
47
|
+
bucket = { count: 0, windowStartMs: now };
|
|
48
|
+
keys.set(key, bucket);
|
|
49
|
+
}
|
|
50
|
+
bucket.count += 1;
|
|
51
|
+
return { ...bucket };
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
keys,
|
|
55
|
+
hitKey,
|
|
56
|
+
hit: jest.fn(),
|
|
57
|
+
hasOpenWindow: jest.fn(),
|
|
58
|
+
sweep: jest.fn(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function rejectingStore(): RateLimitStore {
|
|
63
|
+
return {
|
|
64
|
+
hitKey: jest.fn().mockRejectedValue(new Error("db down")),
|
|
65
|
+
hit: jest.fn(),
|
|
66
|
+
hasOpenWindow: jest.fn(),
|
|
67
|
+
sweep: jest.fn(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const charge = (key: string, now: number, limit = STRIPE_CONNECTION_WRITES_PER_ORG_PER_HOUR) =>
|
|
72
|
+
chargeWriteBudget({ scope: "stripeconn", charges: [{ key, limit }], windowMs: HOUR, now });
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
__resetBuckets();
|
|
76
|
+
mockCreateDatabaseStore.mockReset();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(() => __setWriteBudgetStoreForTests(undefined));
|
|
80
|
+
|
|
81
|
+
describe("chargeWriteBudget (#8907 D9)", () => {
|
|
82
|
+
it("answers ok ten times for one Organization, refuses the eleventh, and opens again an hour later", async () => {
|
|
83
|
+
__setWriteBudgetStoreForTests(countingStore());
|
|
84
|
+
for (let i = 0; i < 10; i++) {
|
|
85
|
+
expect(await charge("org,org_a", T0 + i * 1000)).toEqual({ ok: true });
|
|
86
|
+
}
|
|
87
|
+
const refused = await charge("org,org_a", T0 + 10_000);
|
|
88
|
+
expect(refused.ok).toBe(false);
|
|
89
|
+
if (refused.ok) throw new Error("expected a refusal");
|
|
90
|
+
expect(refused.retryAfterSeconds).toBeGreaterThanOrEqual(1);
|
|
91
|
+
expect(refused.retryAfterSeconds).toBeLessThanOrEqual(3600);
|
|
92
|
+
expect(await charge("org,org_a", T0 + HOUR)).toEqual({ ok: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("counts a person's key independently, at its own limit", async () => {
|
|
96
|
+
__setWriteBudgetStoreForTests(countingStore());
|
|
97
|
+
for (let i = 0; i < 11; i++) await charge("org,org_a", T0 + i);
|
|
98
|
+
for (let i = 0; i < STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR; i++) {
|
|
99
|
+
expect(await charge("user,user_a", T0 + i, STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR)).toEqual({
|
|
100
|
+
ok: true,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
expect((await charge("user,user_a", T0 + 30, STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR)).ok).toBe(
|
|
104
|
+
false,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("refuses when any charge is over, and counts every charge every time", async () => {
|
|
109
|
+
const store = countingStore();
|
|
110
|
+
__setWriteBudgetStoreForTests(store);
|
|
111
|
+
const both = (now: number) =>
|
|
112
|
+
chargeWriteBudget({
|
|
113
|
+
scope: "stripeconn",
|
|
114
|
+
charges: [
|
|
115
|
+
{ key: "org,org_a", limit: 10 },
|
|
116
|
+
{ key: "user,user_a", limit: 20 },
|
|
117
|
+
],
|
|
118
|
+
windowMs: HOUR,
|
|
119
|
+
now,
|
|
120
|
+
});
|
|
121
|
+
for (let i = 0; i < 10; i++) expect((await both(T0 + i)).ok).toBe(true);
|
|
122
|
+
expect((await both(T0 + 10)).ok).toBe(false);
|
|
123
|
+
expect(store.keys.get("writebudget:stripeconn|org,org_a")?.count).toBe(11);
|
|
124
|
+
expect(store.keys.get("writebudget:stripeconn|user,user_a")?.count).toBe(11);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("is not reset by the middleware's per-minute sweep of its shared maps", async () => {
|
|
128
|
+
// A rejecting store sends every count to the fallback, the one state a
|
|
129
|
+
// shared-map sweep could reach if the budget lived there.
|
|
130
|
+
__setWriteBudgetStoreForTests(rejectingStore());
|
|
131
|
+
const past = Date.now() - 30 * 60 * 1000;
|
|
132
|
+
for (let i = 0; i < 10; i++) expect((await charge("org,org_b", past)).ok).toBe(true);
|
|
133
|
+
|
|
134
|
+
const app = Fastify();
|
|
135
|
+
await app.register(rateLimitMiddleware, { windowMs: 60_000, perIpPerMinute: 1_000 });
|
|
136
|
+
app.get("/ping", async () => ({ ok: true }));
|
|
137
|
+
await app.ready();
|
|
138
|
+
// 101 requests reach the interval sweep (`evictStaleBuckets`) at least once.
|
|
139
|
+
for (let i = 0; i < 101; i++) await app.inject({ method: "GET", url: "/ping" });
|
|
140
|
+
await app.close();
|
|
141
|
+
|
|
142
|
+
expect((await charge("org,org_b", past)).ok).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("falls back to a private count capped at MAX_BUCKETS when the store rejects", async () => {
|
|
146
|
+
__setWriteBudgetStoreForTests(rejectingStore());
|
|
147
|
+
for (let i = 0; i < MAX_BUCKETS + 5; i++) await charge(`org,org_${i}`, T0);
|
|
148
|
+
expect(__writeBudgetFallbackCount()).toBe(MAX_BUCKETS);
|
|
149
|
+
// The newest entry survived the cap, the oldest did not.
|
|
150
|
+
for (let i = 0; i < 10; i++) await charge(`org,org_${MAX_BUCKETS + 4}`, T0);
|
|
151
|
+
expect((await charge(`org,org_${MAX_BUCKETS + 4}`, T0)).ok).toBe(false);
|
|
152
|
+
expect((await charge("org,org_0", T0)).ok).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("uses its own database store even with AILK_RATE_LIMIT_STORE=memory, and never the shared maps", async () => {
|
|
156
|
+
const before = { DATABASE_URL: process.env.DATABASE_URL, STORE: process.env.AILK_RATE_LIMIT_STORE };
|
|
157
|
+
process.env.DATABASE_URL = "postgres://budget.invalid/db";
|
|
158
|
+
process.env.AILK_RATE_LIMIT_STORE = "memory";
|
|
159
|
+
try {
|
|
160
|
+
const store = countingStore();
|
|
161
|
+
mockCreateDatabaseStore.mockReturnValue(store);
|
|
162
|
+
__setWriteBudgetStoreForTests(undefined);
|
|
163
|
+
expect((await charge("org,org_c", T0)).ok).toBe(true);
|
|
164
|
+
expect(mockCreateDatabaseStore).toHaveBeenCalledTimes(1);
|
|
165
|
+
expect(store.hitKey).toHaveBeenCalledWith("writebudget:stripeconn|org,org_c", T0, HOUR);
|
|
166
|
+
expect(__bucketCount()).toBe(0);
|
|
167
|
+
} finally {
|
|
168
|
+
process.env.DATABASE_URL = before.DATABASE_URL;
|
|
169
|
+
process.env.AILK_RATE_LIMIT_STORE = before.STORE;
|
|
170
|
+
if (before.DATABASE_URL === undefined) delete process.env.DATABASE_URL;
|
|
171
|
+
if (before.STORE === undefined) delete process.env.AILK_RATE_LIMIT_STORE;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
* spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
|
|
35
35
|
* unexpired, its client enabled and its person's email verified, and only
|
|
36
36
|
* on `HUB_SESSION_ROUTES` — an exact list of METHOD + registered URL
|
|
37
|
-
* pairs — the credential's own `me
|
|
38
|
-
* (#8220, #8515); any other
|
|
39
|
-
*
|
|
37
|
+
* pairs — the credential's own `me`, the member routes and the
|
|
38
|
+
* account-agreements pair, 26 in all (#8220, #8515, #8765); any other
|
|
39
|
+
* route is 404 (never 403, so the account area's `read()` loads
|
|
40
|
+
* "nothing for this caller"). It acts as its person
|
|
40
41
|
* (`request.userId`) and carries `request.hubSession` — never
|
|
41
42
|
* `request.sessionTenant` — and its request logger names the spoke
|
|
42
43
|
* (`spokeId`). Each person is limited, at each spoke, to 60 requests and
|
|
@@ -153,12 +154,14 @@ export type HubSession = {
|
|
|
153
154
|
* The only routes a hub-issued credential reaches (#7894 D6, #8220 D1): an
|
|
154
155
|
* EXACT list of method + registered URL, never a prefix and never computed,
|
|
155
156
|
* so a route added later joins only by a reviewed edit of this list
|
|
156
|
-
* (SEC-7894-3).
|
|
157
|
+
* (SEC-7894-3). Six groups, in this order: the credential's own `me` and
|
|
157
158
|
* #7877's three member reads; #8169's eight member reads; #8170's six member
|
|
158
159
|
* writes (roadmap §7.2 row order); #8438's member consents read and write;
|
|
159
|
-
* #
|
|
160
|
+
* #8765's account-agreements read and write; #8515's onboarding read and
|
|
161
|
+
* write — 26 in all.
|
|
160
162
|
* `GET /v1/consent/platform` is not admitted — it is the Admin owner's own
|
|
161
|
-
* consent to Working Theory, on the hub's cookie session.
|
|
163
|
+
* consent to Working Theory, on the hub's cookie session. Nor is the
|
|
164
|
+
* anonymous `GET /v1/consent/sign-up` (#8765 D2): it takes no credential. The member profile write IS admitted — it
|
|
162
165
|
* is the person's own account, which roadmap D9 saves through it. Not
|
|
163
166
|
* admitted, deliberately: `GET /v1/waitlist-signups/me`,
|
|
164
167
|
* `GET /v1/flow-checkouts/me`, `GET /v1/subscription/me` and
|
|
@@ -191,6 +194,10 @@ export const HUB_SESSION_ROUTES: readonly string[] = Object.freeze([
|
|
|
191
194
|
// admitted: those are the Admin owner's, on the hub's own cookie session.
|
|
192
195
|
"GET /v1/member/consents",
|
|
193
196
|
"POST /v1/member/consents",
|
|
197
|
+
// #8765 D5 — the caller's account-level sign-up agreements, so a spoke's
|
|
198
|
+
// account layout reads and accepts the one grant every site shares.
|
|
199
|
+
"GET /v1/consent/account",
|
|
200
|
+
"POST /v1/consent/account",
|
|
194
201
|
// #8515 D13 — the client's onboarding read and write.
|
|
195
202
|
"GET /v1/member/onboarding",
|
|
196
203
|
"POST /v1/member/onboarding",
|