create-ailk 0.22.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 +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +11 -9
- 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 +7 -5
- package/dist/derive-architecture.js +9 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- 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 +98 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +184 -11
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -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/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 +77 -16
- 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__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- 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__/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/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +10 -7
- 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 +5 -5
- 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 +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- 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/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/index.ts +17 -14
- package/templates/apps/api/src/routes/webhooks/README.md +66 -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 +91 -17
- package/templates/apps/api/src/server.ts +140 -51
- 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 +374 -44
- 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/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- 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 +124 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- 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 +10 -31
- package/templates/apps/web/CLAUDE.md +315 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -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 +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- 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__/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]/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]/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]/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]/team/__tests__/team-routes.test.tsx +4 -1
- 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/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/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -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 +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- 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/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -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 +31 -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 +31 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -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 +30 -2
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- 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/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* '@working-theory/stripe-client' — single chokepoint for AILK-internal ergonomics
|
|
9
9
|
* (logger context, request-id propagation) without touching every consumer.
|
|
10
10
|
*/
|
|
11
|
+
import { logger } from "@working-theory/observability";
|
|
11
12
|
import {
|
|
12
13
|
admitLiveRestrictedKey,
|
|
13
14
|
assertSandboxKey,
|
|
15
|
+
createStripeClientForKey,
|
|
14
16
|
getStripeClient,
|
|
15
17
|
StripeConfigError,
|
|
18
|
+
type StripeConnectionMode,
|
|
16
19
|
} from "@working-theory/stripe-client";
|
|
17
20
|
|
|
18
21
|
export {
|
|
@@ -26,24 +29,37 @@ export {
|
|
|
26
29
|
// Both are on main (#6850, #6862); #8450 is the narrowing this guarded —
|
|
27
30
|
// `assertAdmittedStripeKey` below. This export itself stays sandbox-only.
|
|
28
31
|
assertSandboxKey,
|
|
32
|
+
// The plan checkout's mode assertion and terms-box failure (#9067 D3 step 9).
|
|
33
|
+
CheckoutModeMismatchError,
|
|
34
|
+
// The Organization connection's key classifier (#8906, #8907 D4).
|
|
35
|
+
classifyStripeKey,
|
|
29
36
|
createAdHocPrice,
|
|
30
37
|
createBillingPortalSession,
|
|
31
38
|
createCheckoutSession,
|
|
39
|
+
// The Offerings checkout's per-client calls (#8931 D6), on the connection's client.
|
|
40
|
+
createCheckoutSessionForPrice,
|
|
32
41
|
createPaymentLink,
|
|
33
42
|
createQuotedCheckoutSession,
|
|
43
|
+
expireCheckoutSessionWith,
|
|
44
|
+
findActivePriceByLookupKey,
|
|
34
45
|
getStripeClient,
|
|
46
|
+
// Working Theory's own absolute policies for the plan checkout's terms box (#9067 D3 step 9).
|
|
47
|
+
PLATFORM_TERMS_OF_SERVICE,
|
|
35
48
|
// Checkout session read (#6084 D9) — re-exported through this sole
|
|
36
49
|
// Stripe-touching chokepoint, same as every other Stripe call here.
|
|
37
50
|
retrieveCheckoutSession,
|
|
38
51
|
STRIPE_API_VERSION,
|
|
39
52
|
StripeConfigError,
|
|
40
53
|
StripeWebhookSignatureError,
|
|
54
|
+
TermsOfServiceUrlMissingError,
|
|
41
55
|
toSubscriptionLifecycleStatus,
|
|
42
56
|
// Signed-in Checkout (#6866 D8) — a pinned customer Stripe rejects.
|
|
43
57
|
UnknownCustomerError,
|
|
44
58
|
UnknownProductCodeError,
|
|
45
59
|
UnknownSessionError,
|
|
46
60
|
verifyWebhookSignature,
|
|
61
|
+
// The per-Organization webhook's verify (#8932 D3): the row's secret, no env key.
|
|
62
|
+
verifyWebhookSignatureKeyless,
|
|
47
63
|
WEBHOOK_TOLERANCE_SECONDS,
|
|
48
64
|
} from "@working-theory/stripe-client";
|
|
49
65
|
|
|
@@ -55,6 +71,7 @@ export type {
|
|
|
55
71
|
CreateBillingPortalSessionOutput,
|
|
56
72
|
CreatePaymentLinkInput,
|
|
57
73
|
CreatePaymentLinkOutput,
|
|
74
|
+
CreateCheckoutSessionForPriceInput,
|
|
58
75
|
CreateCheckoutSessionInput,
|
|
59
76
|
CreateCheckoutSessionOutput,
|
|
60
77
|
CreateCheckoutSessionParams,
|
|
@@ -64,6 +81,8 @@ export type {
|
|
|
64
81
|
RetrieveCheckoutSessionOutput,
|
|
65
82
|
Stripe,
|
|
66
83
|
StripeCheckoutSession,
|
|
84
|
+
StripeConnectionMode,
|
|
85
|
+
StripeKeyClass,
|
|
67
86
|
StripeWebhookEvent,
|
|
68
87
|
} from "@working-theory/stripe-client";
|
|
69
88
|
|
|
@@ -81,9 +100,10 @@ let liveKeyAdmitted = false;
|
|
|
81
100
|
* rule, with one fail-closed delta (PR #8470 security review R-1): on Vercel
|
|
82
101
|
* (`VERCEL` set) a missing `VERCEL_ENV` is not production, because
|
|
83
102
|
* `vercel.json` sets `NODE_ENV=production` in every environment, Preview
|
|
84
|
-
* included.
|
|
103
|
+
* included. Exported for the Organization connection's go-live check and
|
|
104
|
+
* its live-row guard (#8907 D3, D8 step 6); the body is unchanged.
|
|
85
105
|
*/
|
|
86
|
-
function isProductionBuild(): boolean {
|
|
106
|
+
export function isProductionBuild(): boolean {
|
|
87
107
|
if (process.env.VERCEL_ENV !== undefined) {
|
|
88
108
|
return process.env.VERCEL_ENV === "production";
|
|
89
109
|
}
|
|
@@ -145,6 +165,35 @@ export function assertAdmittedStripeKey(key: string): void {
|
|
|
145
165
|
assertSandboxKey(key);
|
|
146
166
|
}
|
|
147
167
|
|
|
168
|
+
/**
|
|
169
|
+
* A fresh Stripe client from ONE Organization's own key (#8907 D12, #8906
|
|
170
|
+
* F-T1 to F-T3), or `null` when the factory refuses it: the factory's only
|
|
171
|
+
* caller, so `productionBuild` is always the strict predicate above and never
|
|
172
|
+
* a caller's boolean. It reads and sets no process flag — `stripeKeyMode()`
|
|
173
|
+
* is untouched — so one process holds a live Organization and a test one
|
|
174
|
+
* (contract K6, K7). A refusal logs `{ organizationId, reason }` only.
|
|
175
|
+
* `architecture.yaml` confines its callers to exact paths.
|
|
176
|
+
*/
|
|
177
|
+
export function stripeClientForConnection(input: {
|
|
178
|
+
organizationId: string;
|
|
179
|
+
key: string;
|
|
180
|
+
mode: StripeConnectionMode;
|
|
181
|
+
liveAt: Date | null;
|
|
182
|
+
}): ReturnType<typeof createStripeClientForKey> {
|
|
183
|
+
return createStripeClientForKey({
|
|
184
|
+
organizationId: input.organizationId,
|
|
185
|
+
key: input.key,
|
|
186
|
+
mode: input.mode,
|
|
187
|
+
liveAt: input.liveAt,
|
|
188
|
+
productionBuild: isProductionBuild(),
|
|
189
|
+
log: (refusal) =>
|
|
190
|
+
logger.warn(
|
|
191
|
+
{ organizationId: refusal.organizationId, reason: refusal.reason },
|
|
192
|
+
"[stripe] connection key refused for its mode",
|
|
193
|
+
),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
148
197
|
/** The key class this process admitted at boot: `"live"` only after `admitApiStripeKey()` did. */
|
|
149
198
|
export function stripeKeyMode(): "test" | "live" {
|
|
150
199
|
return liveKeyAdmitted ? "live" : "test";
|
|
@@ -165,14 +214,22 @@ export async function expireCheckoutSession(stripeSessionId: string): Promise<vo
|
|
|
165
214
|
await getStripeClient().checkout.sessions.expire(stripeSessionId);
|
|
166
215
|
}
|
|
167
216
|
|
|
168
|
-
/**
|
|
217
|
+
/**
|
|
218
|
+
* #8171 D7 — what the Billing page shows: the plan's name and the card on
|
|
219
|
+
* file. #9067 D6 adds the renewal date and the site count, read from the
|
|
220
|
+
* same subscription.
|
|
221
|
+
*/
|
|
169
222
|
export type CustomerBillingSummary = {
|
|
170
223
|
planName: string | null;
|
|
171
224
|
paymentMethod: { brand: string; last4: string; expMonth: number; expYear: number } | null;
|
|
225
|
+
renewsAt: string | null;
|
|
226
|
+
sites: number | null;
|
|
172
227
|
};
|
|
173
228
|
|
|
174
229
|
/** A subscription status that still names the customer's plan (#8171 C10). */
|
|
175
230
|
const CURRENT_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due", "unpaid"]);
|
|
231
|
+
/** A subscription status that renews at its period end (#9067 C3). */
|
|
232
|
+
const RENEWING_SUBSCRIPTION_STATUSES = new Set(["active", "trialing", "past_due"]);
|
|
176
233
|
const PLAN_NAME_MAX = 200;
|
|
177
234
|
|
|
178
235
|
/**
|
|
@@ -224,7 +281,20 @@ export async function readCustomerBillingSummary(
|
|
|
224
281
|
?.default_payment_method ?? null;
|
|
225
282
|
}
|
|
226
283
|
|
|
227
|
-
|
|
284
|
+
// #9067 C3, D6 — under `dahlia` the period end lives on the item, and a
|
|
285
|
+
// subscription set to cancel names no renewal.
|
|
286
|
+
const item = current?.items.data[0];
|
|
287
|
+
const renews =
|
|
288
|
+
current !== undefined &&
|
|
289
|
+
RENEWING_SUBSCRIPTION_STATUSES.has(current.status) &&
|
|
290
|
+
current.cancel_at_period_end !== true &&
|
|
291
|
+
current.cancel_at == null &&
|
|
292
|
+
typeof item?.current_period_end === "number";
|
|
293
|
+
const renewsAt = renews ? new Date(item.current_period_end * 1000).toISOString() : null;
|
|
294
|
+
const quantity = item?.quantity;
|
|
295
|
+
const sites = typeof quantity === "number" && Number.isInteger(quantity) && quantity >= 1 ? quantity : null;
|
|
296
|
+
|
|
297
|
+
return { planName, paymentMethod: cardOf(method), renewsAt, sites };
|
|
228
298
|
}
|
|
229
299
|
|
|
230
300
|
/** A `card` payment method's presentation fields, or `null` for anything else. */
|
|
@@ -82,11 +82,75 @@ export function markSeen(eventId: string, nowMs: number = Date.now()): void {
|
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// ─── Per-Organization store (#8932 D8, contract W10) ─────────────────────────
|
|
86
|
+
//
|
|
87
|
+
// The per-Organization endpoint (`POST /v1/webhooks/stripe/:connectionToken`)
|
|
88
|
+
// dedupes in its OWN map, keyed `<organizationId>:<eventId>`, so neither
|
|
89
|
+
// endpoint's dedup can swallow the other's event (SEC-8879-1) and two
|
|
90
|
+
// Organizations sharing one Stripe account never collide (an Organization id
|
|
91
|
+
// holds no `:`). Same TTL, soft cap and sweep rules as the legacy pair above;
|
|
92
|
+
// the legacy `hasSeen`/`markSeen` never read it. Correctness rests on the
|
|
93
|
+
// lead's database key, not on this map.
|
|
94
|
+
|
|
95
|
+
const orgStore = new Map<string, Entry>();
|
|
96
|
+
let orgInsertsSinceLastSweep = 0;
|
|
97
|
+
|
|
98
|
+
function orgKey(organizationId: string, eventId: string): string {
|
|
99
|
+
return `${organizationId}:${eventId}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sweepOrgExpired(nowMs: number): void {
|
|
103
|
+
for (const [key, entry] of orgStore) {
|
|
104
|
+
if (nowMs - entry.insertedAtMs >= IDEMPOTENCY_TTL_MS) {
|
|
105
|
+
orgStore.delete(key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
orgInsertsSinceLastSweep = 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `hasSeen`'s twin for one Organization's endpoint. */
|
|
112
|
+
export function hasSeenForOrganization(
|
|
113
|
+
organizationId: string,
|
|
114
|
+
eventId: string,
|
|
115
|
+
nowMs: number = Date.now(),
|
|
116
|
+
): boolean {
|
|
117
|
+
const key = orgKey(organizationId, eventId);
|
|
118
|
+
const entry = orgStore.get(key);
|
|
119
|
+
if (!entry) return false;
|
|
120
|
+
if (nowMs - entry.insertedAtMs >= IDEMPOTENCY_TTL_MS) {
|
|
121
|
+
orgStore.delete(key);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `markSeen`'s twin for one Organization's endpoint. */
|
|
128
|
+
export function markSeenForOrganization(
|
|
129
|
+
organizationId: string,
|
|
130
|
+
eventId: string,
|
|
131
|
+
nowMs: number = Date.now(),
|
|
132
|
+
): void {
|
|
133
|
+
const key = orgKey(organizationId, eventId);
|
|
134
|
+
if (!orgStore.has(key)) {
|
|
135
|
+
orgStore.set(key, { insertedAtMs: nowMs });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
orgInsertsSinceLastSweep += 1;
|
|
139
|
+
if (
|
|
140
|
+
orgStore.size > SOFT_CAP ||
|
|
141
|
+
orgInsertsSinceLastSweep >= SWEEP_INTERVAL_INSERTS
|
|
142
|
+
) {
|
|
143
|
+
sweepOrgExpired(nowMs);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
85
147
|
/**
|
|
86
|
-
* Test-only reset. Clears all dedup state
|
|
87
|
-
* interfere with each other.
|
|
148
|
+
* Test-only reset. Clears all dedup state — both stores — between test cases
|
|
149
|
+
* so they do not interfere with each other.
|
|
88
150
|
*/
|
|
89
151
|
export function __resetIdempotencyStore(): void {
|
|
90
152
|
store.clear();
|
|
91
153
|
insertsSinceLastSweep = 0;
|
|
154
|
+
orgStore.clear();
|
|
155
|
+
orgInsertsSinceLastSweep = 0;
|
|
92
156
|
}
|
|
@@ -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
|
+
});
|