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
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
* (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book` — and
|
|
66
66
|
* since #7479 the three listing-draft calls a spoke makes for its applicant
|
|
67
67
|
* (`POST /v1/project-listings`, `PATCH /v1/project-listings/drafts/:id`,
|
|
68
|
-
* `POST /v1/project-listings/drafts/:id/submit`)
|
|
68
|
+
* `POST /v1/project-listings/drafts/:id/submit`), and since #8848 the open
|
|
69
|
+
* file read (`GET /v1/deliverables/open/:slug`). The global limiter (`server.ts`) passes the hub's digests, so a listed call is
|
|
69
70
|
* counted per visitor there; a scope's own limiter does so only when its
|
|
70
71
|
* registration passes `trustedKeyDigests` too, as the leads and schedule
|
|
71
72
|
* scopes do. A believed visitor is
|
|
@@ -76,6 +77,21 @@
|
|
|
76
77
|
* request keys on `req.ip`, as before. Spec:
|
|
77
78
|
* docs/specs/7394-the-leads-rate-limit-scope-spec.md.
|
|
78
79
|
*
|
|
80
|
+
* Per-site-key sign-up evidence (#8805): the web's server reads
|
|
81
|
+
* `GET /v1/consent/sign-up` on every sign-up, and every web instance behind
|
|
82
|
+
* one egress address shared one per-IP bucket, which an anonymous caller
|
|
83
|
+
* spoofing that address's `X-Forwarded-For` could fill. A registration that
|
|
84
|
+
* passes `siteKeyOf` (built by `siteKeyReader`) counts a request on one of
|
|
85
|
+
* the `SITE_KEYED_ROUTES` that presents a VERIFIED site key
|
|
86
|
+
* (`x-ailk-spoke-key`, the key the web's api-client already sends) against
|
|
87
|
+
* that key's own `site,<spokeId>` bucket and the scope's per-key ceiling
|
|
88
|
+
* only — never an IP bucket. No other identity starts with `site,` (`req.ip`
|
|
89
|
+
* holds no comma, a forwarded visitor is `v<n>,<ip>`), so no unkeyed caller
|
|
90
|
+
* can reach it. An invalid or absent key, or a verification that fails or
|
|
91
|
+
* outlasts `SITE_KEY_TIMEOUT_MS`, is counted on `req.ip` as before. The key changes
|
|
92
|
+
* the request's rate-limit identity and nothing else: the route still serves
|
|
93
|
+
* the deployment's own site workspace.
|
|
94
|
+
*
|
|
79
95
|
* Scope namespace and env limit (#7394): a registration with a `scope` keys
|
|
80
96
|
* its buckets as `<scope>|<identity>`, so its count is not shared with any
|
|
81
97
|
* other registration, and reads its limits from
|
|
@@ -146,6 +162,13 @@ export type RateLimitOptions = {
|
|
|
146
162
|
* visitors (#7394). Default: 300.
|
|
147
163
|
*/
|
|
148
164
|
perKeyPerMinute?: number;
|
|
165
|
+
/**
|
|
166
|
+
* The verified site key a request presents, as its spoke id, or `null`
|
|
167
|
+
* (#8805). Build it with `siteKeyReader`. A request it names is counted
|
|
168
|
+
* against `<scope>|site,<spokeId>` and `perKeyPerMinute` only, never an IP
|
|
169
|
+
* bucket. Needs a `scope`.
|
|
170
|
+
*/
|
|
171
|
+
siteKeyOf?: (req: FastifyRequest) => Promise<string | null>;
|
|
149
172
|
/**
|
|
150
173
|
* Override the process-default store (#5040 D4). Test-only, like
|
|
151
174
|
* `maxBuckets` — no production caller overrides this.
|
|
@@ -181,6 +204,14 @@ export const MAX_BUCKETS = 10_000;
|
|
|
181
204
|
*/
|
|
182
205
|
export const DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE = 60;
|
|
183
206
|
|
|
207
|
+
/**
|
|
208
|
+
* The sign-up evidence read's per-site-key ceiling (#8805). The web's cache
|
|
209
|
+
* keeps each instance to about one read a minute per enabled locale, so this
|
|
210
|
+
* covers many instances of one site at once.
|
|
211
|
+
* `AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE` overrides it.
|
|
212
|
+
*/
|
|
213
|
+
export const DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE = 120;
|
|
214
|
+
|
|
184
215
|
/**
|
|
185
216
|
* How long a database-store call may run before the middleware falls back to
|
|
186
217
|
* the memory store for that request (#5040 D5). `RateLimitOptions.storeTimeoutMs`
|
|
@@ -218,8 +249,75 @@ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
|
|
|
218
249
|
{ method: "POST", url: "/v1/project-listings" },
|
|
219
250
|
{ method: "PATCH", url: "/v1/project-listings/drafts/:id" },
|
|
220
251
|
{ method: "POST", url: "/v1/project-listings/drafts/:id/submit" },
|
|
252
|
+
// #8848 D5 — an ungated file's bytes, which a site's own route handler
|
|
253
|
+
// reads for its visitor (`/reports/<slug>/file`), in `deliverableopen`.
|
|
254
|
+
{ method: "GET", url: "/v1/deliverables/open/:slug" },
|
|
221
255
|
];
|
|
222
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The only (method, route) pairs on which a verified site key is the
|
|
259
|
+
* caller's rate-limit identity (#8805): the sign-up agreements' evidence read.
|
|
260
|
+
*/
|
|
261
|
+
export const SITE_KEYED_ROUTES: readonly Readonly<{
|
|
262
|
+
method: "GET";
|
|
263
|
+
url: string;
|
|
264
|
+
}>[] = [{ method: "GET", url: "/v1/consent/sign-up" }];
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A verified site key's identity prefix. No other identity starts with it:
|
|
268
|
+
* `req.ip` never holds a comma, and a forwarded visitor is `v<n>,<ip>`.
|
|
269
|
+
*/
|
|
270
|
+
export const SITE_KEY_IDENTITY_PREFIX = "site,";
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* How long a site-key lookup may run before the request is counted per IP
|
|
274
|
+
* instead (#8805) — the store's own bound (#5040 D5).
|
|
275
|
+
*/
|
|
276
|
+
export const SITE_KEY_TIMEOUT_MS = 1_000;
|
|
277
|
+
|
|
278
|
+
const SITE_KEY_HEADER = "x-ailk-spoke-key";
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The `siteKeyOf` reader (#8805). `verify` turns a request's site key into
|
|
282
|
+
* its spoke id, or `null` for a key that is not a registered, unrevoked one;
|
|
283
|
+
* it is called only for a request on one of the `SITE_KEYED_ROUTES` that
|
|
284
|
+
* carries a non-empty `x-ailk-spoke-key`, and at most once per request, so
|
|
285
|
+
* the global limiter and the scope's own share one lookup. A `verify` that
|
|
286
|
+
* throws, or has not answered within `timeoutMs`, reads as `null` — the
|
|
287
|
+
* request is counted per IP, never unlimited — and logs the error's name only.
|
|
288
|
+
*/
|
|
289
|
+
export function siteKeyReader(
|
|
290
|
+
verify: (req: FastifyRequest) => Promise<string | null>,
|
|
291
|
+
timeoutMs: number = SITE_KEY_TIMEOUT_MS,
|
|
292
|
+
): (req: FastifyRequest) => Promise<string | null> {
|
|
293
|
+
const verified = new WeakMap<FastifyRequest, Promise<string | null>>();
|
|
294
|
+
return (req) => {
|
|
295
|
+
const url = req.routeOptions.url ?? "";
|
|
296
|
+
const listed = SITE_KEYED_ROUTES.some(
|
|
297
|
+
(route) => route.method === req.method && route.url === url,
|
|
298
|
+
);
|
|
299
|
+
const presented = req.headers[SITE_KEY_HEADER];
|
|
300
|
+
if (!listed || typeof presented !== "string" || presented.trim() === "") {
|
|
301
|
+
return Promise.resolve(null);
|
|
302
|
+
}
|
|
303
|
+
let pending = verified.get(req);
|
|
304
|
+
if (pending === undefined) {
|
|
305
|
+
pending = withTimeout(verify(req), timeoutMs).catch((err: unknown) => {
|
|
306
|
+
req.log.warn(
|
|
307
|
+
{
|
|
308
|
+
event: "rate_limit_site_key_unverified",
|
|
309
|
+
err: err instanceof Error ? err.name : "unknown",
|
|
310
|
+
},
|
|
311
|
+
"rate_limit_site_key_unverified",
|
|
312
|
+
);
|
|
313
|
+
return null;
|
|
314
|
+
});
|
|
315
|
+
verified.set(req, pending);
|
|
316
|
+
}
|
|
317
|
+
return pending;
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
223
321
|
const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
|
|
224
322
|
const FORWARD_KEY_HEADER = "x-ailk-visitor-forward-key";
|
|
225
323
|
const MIN_FORWARD_KEY_LENGTH = 32;
|
|
@@ -593,6 +691,10 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
593
691
|
done(new Error("rate-limit trustedKeyDigests needs a scope"));
|
|
594
692
|
return;
|
|
595
693
|
}
|
|
694
|
+
if (opts.siteKeyOf !== undefined && scope === undefined) {
|
|
695
|
+
done(new Error("rate-limit siteKeyOf needs a scope"));
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
596
698
|
|
|
597
699
|
const warn = (msg: string) => fastify.log.warn(msg);
|
|
598
700
|
const perIpPerMinute = resolveScopedLimit(
|
|
@@ -608,7 +710,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
608
710
|
opts.perKeyPerMinute ?? DEFAULT_PER_KEY_PER_MINUTE,
|
|
609
711
|
warn,
|
|
610
712
|
);
|
|
611
|
-
const { ipFromRequest } = opts;
|
|
713
|
+
const { ipFromRequest, siteKeyOf } = opts;
|
|
612
714
|
const identityOf: (req: FastifyRequest) => {
|
|
613
715
|
identity: string;
|
|
614
716
|
keyIndex: number | null;
|
|
@@ -631,6 +733,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
631
733
|
activeStore: RateLimitStore,
|
|
632
734
|
ip: string,
|
|
633
735
|
keyIndex: number | null,
|
|
736
|
+
siteId: string | null,
|
|
634
737
|
now: number,
|
|
635
738
|
sweepOnInterval: boolean,
|
|
636
739
|
): Promise<{
|
|
@@ -642,6 +745,23 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
642
745
|
await activeStore.sweep(now, windowMs, "interval");
|
|
643
746
|
}
|
|
644
747
|
|
|
748
|
+
// #8805: a verified site key is counted against its own bucket and the
|
|
749
|
+
// per-key ceiling only — no IP bucket is read or opened.
|
|
750
|
+
if (siteId !== null) {
|
|
751
|
+
const siteBucket = await activeStore.hitKey(
|
|
752
|
+
bucketKey(`${SITE_KEY_IDENTITY_PREFIX}${siteId}`),
|
|
753
|
+
now,
|
|
754
|
+
windowMs,
|
|
755
|
+
);
|
|
756
|
+
const siteOver = siteBucket.count > perKeyPerMinute;
|
|
757
|
+
if (siteOver) await activeStore.sweep(now, windowMs, "limited");
|
|
758
|
+
return {
|
|
759
|
+
tripped: siteOver ? siteBucket : undefined,
|
|
760
|
+
visitorOver: false,
|
|
761
|
+
keyOver: siteOver,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
645
765
|
// #7394 DD-8: a keyed request also counts against its key's own
|
|
646
766
|
// bucket, even when the visitor bucket is already over its limit.
|
|
647
767
|
const keyBucket =
|
|
@@ -675,7 +795,11 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
675
795
|
"onRequest",
|
|
676
796
|
async (req: FastifyRequest, reply: FastifyReply) => {
|
|
677
797
|
const now = getClock().now();
|
|
678
|
-
const
|
|
798
|
+
const siteId = siteKeyOf ? await siteKeyOf(req) : null;
|
|
799
|
+
const { identity: ip, keyIndex } =
|
|
800
|
+
siteId === null
|
|
801
|
+
? identityOf(req)
|
|
802
|
+
: { identity: `${SITE_KEY_IDENTITY_PREFIX}${siteId}`, keyIndex: null };
|
|
679
803
|
|
|
680
804
|
requestsSinceLastClean += 1;
|
|
681
805
|
const sweepOnInterval = requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS;
|
|
@@ -697,7 +821,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
697
821
|
};
|
|
698
822
|
try {
|
|
699
823
|
outcome = await withTimeout(
|
|
700
|
-
countRequest(primaryStore, ip, keyIndex, now, sweepOnInterval),
|
|
824
|
+
countRequest(primaryStore, ip, keyIndex, siteId, now, sweepOnInterval),
|
|
701
825
|
storeTimeoutMs,
|
|
702
826
|
);
|
|
703
827
|
} catch (err) {
|
|
@@ -710,6 +834,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
710
834
|
fallbackStore,
|
|
711
835
|
ip,
|
|
712
836
|
keyIndex,
|
|
837
|
+
siteId,
|
|
713
838
|
now,
|
|
714
839
|
sweepOnInterval,
|
|
715
840
|
);
|
|
@@ -730,6 +855,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
730
855
|
const pathOnly = req.url.split("?")[0] ?? req.url;
|
|
731
856
|
// A keyed 429 names the bucket that tripped and the key's position,
|
|
732
857
|
// so a misused key can be traced to its spoke; never the key itself.
|
|
858
|
+
// A site-keyed 429 (#8805) names `bucket: "site"` and the spoke id.
|
|
733
859
|
req.log.warn(
|
|
734
860
|
{
|
|
735
861
|
ip,
|
|
@@ -739,6 +865,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
739
865
|
bucket: visitorOver ? "visitor" : "key",
|
|
740
866
|
keyIndex,
|
|
741
867
|
}),
|
|
868
|
+
...(siteId !== null && { bucket: "site" }),
|
|
742
869
|
},
|
|
743
870
|
RATE_LIMITED,
|
|
744
871
|
);
|
|
@@ -759,3 +886,98 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
759
886
|
(rateLimitMiddleware as FastifyPluginCallback & { [key: symbol]: boolean })[
|
|
760
887
|
Symbol.for("skip-override")
|
|
761
888
|
] = true;
|
|
889
|
+
|
|
890
|
+
// ─── The Stripe connection write budget (#8907 D9, contract K10) ────────────
|
|
891
|
+
|
|
892
|
+
/** One Organization's connection writes in an hour (K10). */
|
|
893
|
+
export const STRIPE_CONNECTION_WRITES_PER_ORG_PER_HOUR = 10;
|
|
894
|
+
/** One person's connection writes in an hour, across Organizations (K10). */
|
|
895
|
+
export const STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR = 20;
|
|
896
|
+
|
|
897
|
+
/** One bucket a write is counted against: `org,<id>` or `user,<id>`. */
|
|
898
|
+
export type WriteBudgetCharge = { key: string; limit: number };
|
|
899
|
+
|
|
900
|
+
export type WriteBudgetResult = { ok: true } | { ok: false; retryAfterSeconds: number };
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* The budget's own database store (D9, SEC-8907-2) — never the process
|
|
904
|
+
* default, so `AILK_RATE_LIMIT_STORE=memory` can never send an hourly bucket
|
|
905
|
+
* into the shared `keyBuckets` map a per-minute sweep clears. Each database
|
|
906
|
+
* row keeps its own `window_ms`, so no sweep shortens it.
|
|
907
|
+
*/
|
|
908
|
+
let writeBudgetStore: RateLimitStore | undefined;
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Where a request is counted while the database store rejects or times out.
|
|
912
|
+
* Module-private and never touched by `evictStaleBuckets`; capped at
|
|
913
|
+
* `MAX_BUCKETS`, the oldest entry dropped first, and an entry replaced only
|
|
914
|
+
* once its own window has ended. Per instance while the database is
|
|
915
|
+
* degraded — the residual SEC-8907-15 accepts.
|
|
916
|
+
*/
|
|
917
|
+
const writeBudgetFallback = new Map<string, RateLimitBucket>();
|
|
918
|
+
|
|
919
|
+
function fallbackHit(key: string, now: number, windowMs: number): RateLimitBucket {
|
|
920
|
+
let bucket = writeBudgetFallback.get(key);
|
|
921
|
+
if (!bucket || now - bucket.windowStartMs >= windowMs) {
|
|
922
|
+
writeBudgetFallback.delete(key);
|
|
923
|
+
if (writeBudgetFallback.size >= MAX_BUCKETS) {
|
|
924
|
+
const oldestKey = writeBudgetFallback.keys().next().value;
|
|
925
|
+
if (oldestKey !== undefined) writeBudgetFallback.delete(oldestKey);
|
|
926
|
+
}
|
|
927
|
+
bucket = { count: 0, windowStartMs: now };
|
|
928
|
+
writeBudgetFallback.set(key, bucket);
|
|
929
|
+
}
|
|
930
|
+
bucket.count += 1;
|
|
931
|
+
return bucket;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Count one write against every charge's bucket and answer whether all of
|
|
936
|
+
* them are still within their limit (D9). Every call counts — a refused key
|
|
937
|
+
* costs a write too, so the probes are never a key-validity oracle. On a
|
|
938
|
+
* refusal, `retryAfterSeconds` is the latest window end among the refusing
|
|
939
|
+
* buckets, at least 1.
|
|
940
|
+
*/
|
|
941
|
+
export async function chargeWriteBudget(input: {
|
|
942
|
+
scope: string;
|
|
943
|
+
charges: readonly WriteBudgetCharge[];
|
|
944
|
+
windowMs: number;
|
|
945
|
+
now: number;
|
|
946
|
+
warn?: (obj: Record<string, unknown>, msg: string) => void;
|
|
947
|
+
}): Promise<WriteBudgetResult> {
|
|
948
|
+
const { scope, charges, windowMs, now } = input;
|
|
949
|
+
writeBudgetStore ??= createDatabaseRateLimitStore(prisma);
|
|
950
|
+
const activeStore = writeBudgetStore;
|
|
951
|
+
|
|
952
|
+
let retryAfterSeconds = 0;
|
|
953
|
+
for (const charge of charges) {
|
|
954
|
+
const key = `writebudget:${scope}|${charge.key}`;
|
|
955
|
+
let bucket: RateLimitBucket;
|
|
956
|
+
try {
|
|
957
|
+
bucket = await withTimeout(activeStore.hitKey(key, now, windowMs), STORE_TIMEOUT_MS);
|
|
958
|
+
} catch (err) {
|
|
959
|
+
warnFallback(
|
|
960
|
+
input.warn ?? (() => undefined),
|
|
961
|
+
now,
|
|
962
|
+
err instanceof RateLimitStoreTimeoutError ? "timeout" : "error",
|
|
963
|
+
);
|
|
964
|
+
bucket = fallbackHit(key, now, windowMs);
|
|
965
|
+
}
|
|
966
|
+
if (bucket.count > charge.limit) {
|
|
967
|
+
const endsInMs = bucket.windowStartMs + windowMs - now;
|
|
968
|
+
retryAfterSeconds = Math.max(retryAfterSeconds, Math.ceil(endsInMs / 1000), 1);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return retryAfterSeconds > 0 ? { ok: false, retryAfterSeconds } : { ok: true };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** Test-only: replace the budget's store, as the middleware's `store` option does for a registration. */
|
|
975
|
+
export function __setWriteBudgetStoreForTests(store: RateLimitStore | undefined): void {
|
|
976
|
+
writeBudgetStore = store;
|
|
977
|
+
writeBudgetFallback.clear();
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** Test-only: the fallback map's size. */
|
|
981
|
+
export function __writeBudgetFallbackCount(): number {
|
|
982
|
+
return writeBudgetFallback.size;
|
|
983
|
+
}
|
|
@@ -758,12 +758,14 @@ export function buildOpenApiSpec(
|
|
|
758
758
|
"lead's id with a 201; with anything different it is a 422. Keys are " +
|
|
759
759
|
"scoped per workspace, are first-write-wins, and should be unguessable " +
|
|
760
760
|
"(a UUID, a Stripe Checkout Session id). Without a database there is no " +
|
|
761
|
-
"stored lead to match, so the key has no effect. A
|
|
762
|
-
"`source` `contact-form`, `conversational-intake
|
|
763
|
-
"must carry `consent: true` (the visitor
|
|
761
|
+
"stored lead to match, so the key has no effect. A consent-bearing lead — " +
|
|
762
|
+
"`source` `contact-form`, `conversational-intake`, `flow:contact` or " +
|
|
763
|
+
"`flow:job-application` — must carry `consent: true` (the visitor " +
|
|
764
|
+
"accepted the site's terms), " +
|
|
764
765
|
"else it is a 400 `consent_required`; the server then records " +
|
|
765
766
|
"`payload.consent` (`statement`, `acceptedAt`) itself, replacing any the " +
|
|
766
|
-
"caller sent
|
|
767
|
+
"caller sent, and leaves it out of the Idempotency-Key replay " +
|
|
768
|
+
"comparison. Other sources do not read `consent`.",
|
|
767
769
|
parameters: [
|
|
768
770
|
{
|
|
769
771
|
name: "Idempotency-Key",
|
|
@@ -795,8 +797,8 @@ export function buildOpenApiSpec(
|
|
|
795
797
|
"400": {
|
|
796
798
|
description:
|
|
797
799
|
"Validation error — invalid request body, or a malformed " +
|
|
798
|
-
"Idempotency-Key; or `consent_required`, a
|
|
799
|
-
"`consent: true`",
|
|
800
|
+
"Idempotency-Key; or `consent_required`, a consent-bearing lead " +
|
|
801
|
+
"(a contact lead or a job application) without `consent: true`",
|
|
800
802
|
content: {
|
|
801
803
|
"application/json": {
|
|
802
804
|
schema: {
|
|
@@ -1488,7 +1490,8 @@ export function buildOpenApiSpec(
|
|
|
1488
1490
|
type: "boolean",
|
|
1489
1491
|
description:
|
|
1490
1492
|
"`true` when the visitor accepted the site's terms. Required (as " +
|
|
1491
|
-
"`true`) for a
|
|
1493
|
+
"`true`) for a consent-bearing lead — a contact lead or a job " +
|
|
1494
|
+
"application; read for no other source, and never " +
|
|
1492
1495
|
"stored as sent — the server records `payload.consent` instead.",
|
|
1493
1496
|
},
|
|
1494
1497
|
payload: {
|