create-ailk 0.19.1 → 0.21.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/README.md +84 -7
- package/component-catalog.json +184 -6
- package/component-catalog.md +20 -9
- package/dist/apply-design.d.ts +9 -0
- package/dist/apply-design.js +78 -31
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/design-home.d.ts +54 -11
- package/dist/design-home.js +160 -22
- package/dist/lib/design-checks.d.ts +6 -1
- package/dist/lib/design-checks.js +23 -2
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/module-architecture.js +55 -2
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +76 -22
- package/dist/site-roster.d.ts +4 -2
- package/dist/site-roster.js +7 -5
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/starter-grid.js +16 -0
- package/dist/surfaces.js +78 -2
- package/dist/sync-routes.d.ts +28 -0
- package/dist/sync-routes.js +123 -1
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/update.d.ts +53 -0
- package/dist/update.js +606 -0
- package/package.json +3 -3
- package/site-templates/_base/ailk-template.json +2 -2
- package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
- package/site-templates/designs/minimalism/DESIGN.md +1 -1
- package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
- package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
- package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/README.md +26 -25
- package/templates/_gitignore +162 -0
- package/templates/apps/api/CLAUDE.md +18 -10
- package/templates/apps/api/jest.config.cjs +1 -0
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
- package/templates/apps/api/src/middleware/auth.ts +8 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
- package/templates/apps/api/src/server.ts +14 -2
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/project-listings.ts +6 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +139 -44
- package/templates/apps/mcp/jest.config.cjs +1 -0
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/CLAUDE.md +26 -1
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
- package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
- package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
- package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +48 -4
- package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- 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 +46 -4
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/products/__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]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
- package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/jest.config.cjs +4 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +100 -9
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/deck-page.ts +186 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/messages/en.json +83 -9
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +48 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/__tests__/seed.test.ts +39 -2
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +38 -12
- package/templates/database/jest.config.cjs +1 -0
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +29 -0
- package/templates/project.yaml +6 -3
- package/templates/tools/jest/pin-timezone.cjs +34 -0
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-IP rate limit middleware for anonymous write routes.
|
|
3
3
|
*
|
|
4
|
-
* SINGLE-REPLICA CAVEAT:
|
|
5
|
-
* counts. Each Fastify instance maintains its own bucket state.
|
|
6
|
-
*
|
|
7
|
-
* up to N × perIpPerMinute requests per minute per IP,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* SINGLE-REPLICA CAVEAT: the memory store below keeps an in-process Map of
|
|
5
|
+
* request counts. Each Fastify instance maintains its own bucket state. On a
|
|
6
|
+
* deployment with no database (or `AILK_RATE_LIMIT_STORE=memory`), an
|
|
7
|
+
* attacker can issue up to N × perIpPerMinute requests per minute per IP,
|
|
8
|
+
* where N is the replica/isolate count.
|
|
9
|
+
*
|
|
10
|
+
* SHARED STORE (#5040): on a deployment with a database, every registration
|
|
11
|
+
* defaults to counting in `rate_limit_counters` instead (D4), so one
|
|
12
|
+
* visitor's limit holds across every concurrent isolate. See
|
|
13
|
+
* `apps/api/src/lib/rate-limit-store.ts` for the store seam and
|
|
14
|
+
* `docs/specs/5040-the-api-s-rate-limiter-spec.md` D11 for the full list of
|
|
15
|
+
* what this protects against and what it does not — in short: it stops one
|
|
16
|
+
* identity from exceeding its ceiling by spreading requests over isolates,
|
|
17
|
+
* but a caller that mints a fresh identity per request (a forged
|
|
18
|
+
* `x-forwarded-for` the edge passes through unmodified) still gets a fresh
|
|
19
|
+
* bucket every time, in either store.
|
|
11
20
|
*
|
|
12
21
|
* Cleanup strategy: evict stale buckets on every 429 emission and every 100
|
|
13
22
|
* allowed requests (bounded, O(n) over bucket count — negligible for typical
|
|
@@ -23,7 +32,9 @@
|
|
|
23
32
|
* caps total entries independent of the time-based eviction: once at
|
|
24
33
|
* capacity, a genuinely new identity evicts the single oldest-inserted entry
|
|
25
34
|
* (`Map` iteration order is insertion order) before inserting itself — O(1)
|
|
26
|
-
* amortized, no behavior change for any caller under the cap.
|
|
35
|
+
* amortized, no behavior change for any caller under the cap. The database
|
|
36
|
+
* store has no such cap: it relies entirely on the sweep (D6) to bound its
|
|
37
|
+
* row count.
|
|
27
38
|
*
|
|
28
39
|
* Trust-proxy boundary: this middleware reads req.ip, which Fastify populates
|
|
29
40
|
* from the socket address and any trusted proxy headers (X-Forwarded-For) per
|
|
@@ -51,8 +62,10 @@
|
|
|
51
62
|
* believes only alongside a hub-issued key in `x-ailk-visitor-forward-key`,
|
|
52
63
|
* and only on the `FORWARDED_VISITOR_ROUTES`: the three lead writes, and
|
|
53
64
|
* since #7633 the two schedule calls a site's same-origin relay forwards
|
|
54
|
-
* (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book
|
|
55
|
-
*
|
|
65
|
+
* (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book` — and
|
|
66
|
+
* since #7479 the three listing-draft calls a spoke makes for its applicant
|
|
67
|
+
* (`POST /v1/project-listings`, `PATCH /v1/project-listings/drafts/:id`,
|
|
68
|
+
* `POST /v1/project-listings/drafts/:id/submit`). The global limiter (`server.ts`) passes the hub's digests, so a listed call is
|
|
56
69
|
* counted per visitor there; a scope's own limiter does so only when its
|
|
57
70
|
* registration passes `trustedKeyDigests` too, as the leads and schedule
|
|
58
71
|
* scopes do. A believed visitor is
|
|
@@ -78,8 +91,17 @@ import type {
|
|
|
78
91
|
FastifyRequest,
|
|
79
92
|
FastifyReply,
|
|
80
93
|
FastifyPluginCallback,
|
|
94
|
+
FastifyBaseLogger,
|
|
81
95
|
} from "fastify";
|
|
82
96
|
|
|
97
|
+
import { prisma } from "../lib/prisma.js";
|
|
98
|
+
import {
|
|
99
|
+
createDatabaseRateLimitStore,
|
|
100
|
+
resolveRateLimitStoreKind,
|
|
101
|
+
type RateLimitBucket,
|
|
102
|
+
type RateLimitStore,
|
|
103
|
+
} from "../lib/rate-limit-store.js";
|
|
104
|
+
|
|
83
105
|
export type RateLimitOptions = {
|
|
84
106
|
/**
|
|
85
107
|
* Requests allowed per IP per window.
|
|
@@ -101,7 +123,8 @@ export type RateLimitOptions = {
|
|
|
101
123
|
/**
|
|
102
124
|
* Maximum distinct entries `buckets` may hold at once, independent of the
|
|
103
125
|
* time-based eviction. Default: `MAX_BUCKETS`. Exposed for tests only —
|
|
104
|
-
* no production caller overrides this.
|
|
126
|
+
* no production caller overrides this. Has no effect on the database
|
|
127
|
+
* store, which has no entry cap of its own (D6's sweep bounds it instead).
|
|
105
128
|
*/
|
|
106
129
|
maxBuckets?: number;
|
|
107
130
|
/**
|
|
@@ -123,11 +146,15 @@ export type RateLimitOptions = {
|
|
|
123
146
|
* visitors (#7394). Default: 300.
|
|
124
147
|
*/
|
|
125
148
|
perKeyPerMinute?: number;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Override the process-default store (#5040 D4). Test-only, like
|
|
151
|
+
* `maxBuckets` — no production caller overrides this.
|
|
152
|
+
*/
|
|
153
|
+
store?: RateLimitStore;
|
|
154
|
+
/**
|
|
155
|
+
* Override `STORE_TIMEOUT_MS` (#5040 D5). Test-only.
|
|
156
|
+
*/
|
|
157
|
+
storeTimeoutMs?: number;
|
|
131
158
|
};
|
|
132
159
|
|
|
133
160
|
const RATE_LIMITED = "rate_limited" as const;
|
|
@@ -154,15 +181,26 @@ export const MAX_BUCKETS = 10_000;
|
|
|
154
181
|
*/
|
|
155
182
|
export const DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE = 60;
|
|
156
183
|
|
|
184
|
+
/**
|
|
185
|
+
* How long a database-store call may run before the middleware falls back to
|
|
186
|
+
* the memory store for that request (#5040 D5). `RateLimitOptions.storeTimeoutMs`
|
|
187
|
+
* overrides it, test-only.
|
|
188
|
+
*/
|
|
189
|
+
export const STORE_TIMEOUT_MS = 1_000;
|
|
190
|
+
|
|
191
|
+
/** How often a `rate_limit_store_fallback` warn line may be logged (D5). */
|
|
192
|
+
const FALLBACK_WARN_THROTTLE_MS = 60_000;
|
|
193
|
+
|
|
157
194
|
/**
|
|
158
195
|
* The only (method, route) pairs on which a forwarded visitor IP is believed
|
|
159
|
-
* (#7394 DD-2): the three lead writes,
|
|
196
|
+
* (#7394 DD-2): the three lead writes, the two schedule calls a site's
|
|
160
197
|
* relay forwards on its visitors' behalf (#7633, the relay's security
|
|
161
|
-
* review)
|
|
198
|
+
* review), the two consent writes (#8438) and the three listing-draft calls
|
|
199
|
+
* (#7479). Matched against `req.method` and `req.routeOptions.url`, the
|
|
162
200
|
* registered route pattern.
|
|
163
201
|
*/
|
|
164
202
|
export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
|
|
165
|
-
method: "GET" | "POST";
|
|
203
|
+
method: "GET" | "POST" | "PATCH";
|
|
166
204
|
url: string;
|
|
167
205
|
}>[] = [
|
|
168
206
|
{ method: "POST", url: "/v1/leads/submit" },
|
|
@@ -175,6 +213,11 @@ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
|
|
|
175
213
|
// counted one by one rather than in one bucket keyed on its egress IP.
|
|
176
214
|
{ method: "POST", url: "/v1/consent/cookie" },
|
|
177
215
|
{ method: "POST", url: "/v1/consent/unsubscribe" },
|
|
216
|
+
// #7479 — the listing-draft lifecycle a spoke's Server Action calls on its
|
|
217
|
+
// applicant's behalf (`start`, the draft `patch`, `submit`), in `leadsScope`.
|
|
218
|
+
{ method: "POST", url: "/v1/project-listings" },
|
|
219
|
+
{ method: "PATCH", url: "/v1/project-listings/drafts/:id" },
|
|
220
|
+
{ method: "POST", url: "/v1/project-listings/drafts/:id/submit" },
|
|
178
221
|
];
|
|
179
222
|
|
|
180
223
|
const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
|
|
@@ -335,16 +378,17 @@ function resolveScopedLimit(
|
|
|
335
378
|
return fallback;
|
|
336
379
|
}
|
|
337
380
|
|
|
338
|
-
// Module-scoped bucket store
|
|
339
|
-
// (#7394). Per-forwarding-key buckets
|
|
340
|
-
|
|
381
|
+
// Module-scoped bucket store (the memory store's backing state). One entry
|
|
382
|
+
// per unique identity, per named scope (#7394). Per-forwarding-key buckets
|
|
383
|
+
// live in `keyBuckets` below.
|
|
384
|
+
const buckets = new Map<string, RateLimitBucket>();
|
|
341
385
|
|
|
342
386
|
// Per-forwarding-key buckets (#7394 DD-8), kept apart from `buckets` so the
|
|
343
387
|
// `maxBuckets` FIFO eviction can never drop one: a keyed caller churning
|
|
344
388
|
// visitor IPs would otherwise push its own key bucket out and reset its
|
|
345
389
|
// ceiling. At most one entry per configured key per named scope, so it
|
|
346
390
|
// needs no capacity cap of its own.
|
|
347
|
-
const keyBuckets = new Map<string,
|
|
391
|
+
const keyBuckets = new Map<string, RateLimitBucket>();
|
|
348
392
|
|
|
349
393
|
// Request counter used to trigger periodic cleanup.
|
|
350
394
|
let requestsSinceLastClean = 0;
|
|
@@ -361,7 +405,6 @@ function evictStaleBuckets(now: number, windowMs: number): void {
|
|
|
361
405
|
keyBuckets.delete(key);
|
|
362
406
|
}
|
|
363
407
|
}
|
|
364
|
-
requestsSinceLastClean = 0;
|
|
365
408
|
}
|
|
366
409
|
|
|
367
410
|
/**
|
|
@@ -382,6 +425,145 @@ export function __bucketCount(): number {
|
|
|
382
425
|
return buckets.size;
|
|
383
426
|
}
|
|
384
427
|
|
|
428
|
+
/**
|
|
429
|
+
* The memory store (#5040 D1/D7): the Maps and eviction logic above, wrapped
|
|
430
|
+
* behind the `RateLimitStore` seam, unchanged in behavior. `maxBuckets` is
|
|
431
|
+
* captured per call (registrations may pass their own, test-only) but every
|
|
432
|
+
* instance still operates on the SAME shared `buckets` / `keyBuckets` Maps —
|
|
433
|
+
* there has never been more than one memory store's worth of state in this
|
|
434
|
+
* process.
|
|
435
|
+
*/
|
|
436
|
+
function memoryRateLimitStore(maxBuckets: number): RateLimitStore {
|
|
437
|
+
function hit(key: string, now: number, windowMs: number): Promise<RateLimitBucket> {
|
|
438
|
+
let bucket = buckets.get(key);
|
|
439
|
+
|
|
440
|
+
if (!bucket || now - bucket.windowStartMs >= windowMs) {
|
|
441
|
+
// A genuinely new distinct identity (not a window roll on an
|
|
442
|
+
// existing bucket) at capacity evicts the single oldest-inserted
|
|
443
|
+
// entry first — independent of the time-based eviction above,
|
|
444
|
+
// which only removes EXPIRED windows and may leave `buckets` at or
|
|
445
|
+
// above `maxBuckets` when every entry is still within its window.
|
|
446
|
+
if (!buckets.has(key) && buckets.size >= maxBuckets) {
|
|
447
|
+
const oldestKey = buckets.keys().next().value;
|
|
448
|
+
if (oldestKey !== undefined) {
|
|
449
|
+
buckets.delete(oldestKey);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
bucket = { count: 0, windowStartMs: now };
|
|
453
|
+
buckets.set(key, bucket);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
bucket.count += 1;
|
|
457
|
+
return Promise.resolve(bucket);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function hitKey(key: string, now: number, windowMs: number): Promise<RateLimitBucket> {
|
|
461
|
+
let bucket = keyBuckets.get(key);
|
|
462
|
+
if (!bucket || now - bucket.windowStartMs >= windowMs) {
|
|
463
|
+
bucket = { count: 0, windowStartMs: now };
|
|
464
|
+
keyBuckets.set(key, bucket);
|
|
465
|
+
}
|
|
466
|
+
bucket.count += 1;
|
|
467
|
+
return Promise.resolve(bucket);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function hasOpenWindow(key: string, now: number, windowMs: number): Promise<boolean> {
|
|
471
|
+
const bucket = buckets.get(key);
|
|
472
|
+
return Promise.resolve(
|
|
473
|
+
bucket !== undefined && now - bucket.windowStartMs < windowMs,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// D6 — the memory store sweeps on both triggers, exactly as
|
|
478
|
+
// evictStaleBuckets always has; `reason` only matters to the database
|
|
479
|
+
// store.
|
|
480
|
+
function sweep(now: number, windowMs: number): Promise<void> {
|
|
481
|
+
evictStaleBuckets(now, windowMs);
|
|
482
|
+
return Promise.resolve();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return { hit, hitKey, hasOpenWindow, sweep };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// The process-wide default store (#5040 D4), resolved once and memoized —
|
|
489
|
+
// NOT reset by __resetBuckets(), since only a fresh module instance
|
|
490
|
+
// (jest.isolateModules) changes which store the environment resolves to.
|
|
491
|
+
let processStoreKind: "memory" | "database" | undefined;
|
|
492
|
+
let processDatabaseStore: RateLimitStore | undefined;
|
|
493
|
+
let loggedStoreKind = false;
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* A rejection or timeout fired this long ago, within `FALLBACK_WARN_THROTTLE_MS`
|
|
497
|
+
* — do not log another `rate_limit_store_fallback` line yet.
|
|
498
|
+
*/
|
|
499
|
+
let lastFallbackWarnAt: number | undefined;
|
|
500
|
+
|
|
501
|
+
/** The process default store for a registration with no explicit `store` option. */
|
|
502
|
+
function resolveProcessDefaultStore(
|
|
503
|
+
warn: (msg: string) => void,
|
|
504
|
+
log: Pick<FastifyBaseLogger, "info">,
|
|
505
|
+
maxBuckets: number,
|
|
506
|
+
): RateLimitStore {
|
|
507
|
+
if (processStoreKind === undefined) {
|
|
508
|
+
processStoreKind = resolveRateLimitStoreKind(
|
|
509
|
+
{
|
|
510
|
+
DATABASE_URL: process.env.DATABASE_URL,
|
|
511
|
+
AILK_RATE_LIMIT_STORE: process.env.AILK_RATE_LIMIT_STORE,
|
|
512
|
+
},
|
|
513
|
+
warn,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (!loggedStoreKind) {
|
|
517
|
+
loggedStoreKind = true;
|
|
518
|
+
log.info({ rateLimitStore: processStoreKind }, "rate_limit_store");
|
|
519
|
+
}
|
|
520
|
+
if (processStoreKind === "database") {
|
|
521
|
+
processDatabaseStore ??= createDatabaseRateLimitStore(prisma);
|
|
522
|
+
return processDatabaseStore;
|
|
523
|
+
}
|
|
524
|
+
return memoryRateLimitStore(maxBuckets);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
class RateLimitStoreTimeoutError extends Error {}
|
|
528
|
+
|
|
529
|
+
/** Reject with a `RateLimitStoreTimeoutError` if `promise` has not settled within `ms`. */
|
|
530
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
531
|
+
return new Promise<T>((resolve, reject) => {
|
|
532
|
+
const timer = setTimeout(() => {
|
|
533
|
+
reject(new RateLimitStoreTimeoutError("rate limit store call timed out"));
|
|
534
|
+
}, ms);
|
|
535
|
+
promise.then(
|
|
536
|
+
(value) => {
|
|
537
|
+
clearTimeout(timer);
|
|
538
|
+
resolve(value);
|
|
539
|
+
},
|
|
540
|
+
(err: unknown) => {
|
|
541
|
+
clearTimeout(timer);
|
|
542
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
543
|
+
},
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Log at most one `rate_limit_store_fallback` warn per `FALLBACK_WARN_THROTTLE_MS`
|
|
550
|
+
* per process (#5040 D5). Carries no key, IP, hash or error message.
|
|
551
|
+
*/
|
|
552
|
+
function warnFallback(
|
|
553
|
+
warn: (obj: Record<string, unknown>, msg: string) => void,
|
|
554
|
+
now: number,
|
|
555
|
+
reason: "error" | "timeout",
|
|
556
|
+
): void {
|
|
557
|
+
if (
|
|
558
|
+
lastFallbackWarnAt !== undefined &&
|
|
559
|
+
now - lastFallbackWarnAt < FALLBACK_WARN_THROTTLE_MS
|
|
560
|
+
) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
lastFallbackWarnAt = now;
|
|
564
|
+
warn({ event: "rate_limit_store_fallback", reason }, "rate_limit_store_fallback");
|
|
565
|
+
}
|
|
566
|
+
|
|
385
567
|
// Break Fastify's plugin encapsulation so the onRequest hook propagates to
|
|
386
568
|
// parent and sibling scopes. This is the manual equivalent of fastify-plugin's
|
|
387
569
|
// `skip-override` mechanism — documented in Fastify's plugin guide.
|
|
@@ -396,6 +578,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
396
578
|
maxBuckets = MAX_BUCKETS,
|
|
397
579
|
scope,
|
|
398
580
|
trustedKeyDigests = [],
|
|
581
|
+
storeTimeoutMs = STORE_TIMEOUT_MS,
|
|
399
582
|
} = opts;
|
|
400
583
|
|
|
401
584
|
if (scope !== undefined && !SCOPE_RE.test(scope)) {
|
|
@@ -435,45 +618,57 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
435
618
|
const bucketKey = (identity: string) =>
|
|
436
619
|
scope === undefined ? identity : `${scope}|${identity}`;
|
|
437
620
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
621
|
+
const fallbackStore = memoryRateLimitStore(maxBuckets);
|
|
622
|
+
const primaryStore: RateLimitStore =
|
|
623
|
+
opts.store ?? resolveProcessDefaultStore(warn, fastify.log, maxBuckets);
|
|
441
624
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
625
|
+
/**
|
|
626
|
+
* Run the whole request's counting against `activeStore` (D7's unchanged
|
|
627
|
+
* control flow: hitKey → keyOver → R-1 check (hasOpenWindow) → hit →
|
|
628
|
+
* tripped), plus the periodic/on-429 sweep (D6).
|
|
629
|
+
*/
|
|
630
|
+
async function countRequest(
|
|
631
|
+
activeStore: RateLimitStore,
|
|
632
|
+
ip: string,
|
|
633
|
+
keyIndex: number | null,
|
|
634
|
+
now: number,
|
|
635
|
+
sweepOnInterval: boolean,
|
|
636
|
+
): Promise<{
|
|
637
|
+
tripped: RateLimitBucket | undefined;
|
|
638
|
+
visitorOver: boolean;
|
|
639
|
+
keyOver: boolean;
|
|
640
|
+
}> {
|
|
641
|
+
if (sweepOnInterval) {
|
|
642
|
+
await activeStore.sweep(now, windowMs, "interval");
|
|
456
643
|
}
|
|
457
644
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
645
|
+
// #7394 DD-8: a keyed request also counts against its key's own
|
|
646
|
+
// bucket, even when the visitor bucket is already over its limit.
|
|
647
|
+
const keyBucket =
|
|
648
|
+
keyIndex === null
|
|
649
|
+
? undefined
|
|
650
|
+
: await activeStore.hitKey(`${scope}|key,${keyIndex}`, now, windowMs);
|
|
651
|
+
const keyOver =
|
|
652
|
+
keyBucket !== undefined && keyBucket.count > perKeyPerMinute;
|
|
653
|
+
// A key over its ceiling opens no NEW visitor bucket (#7394 security
|
|
654
|
+
// review R-1). Each new entry can evict the oldest one in `buckets`,
|
|
655
|
+
// another scope's counter included, so without this a key holder
|
|
656
|
+
// rotating visitor IPs could wipe every strict limiter at will. A
|
|
657
|
+
// visitor whose window is still open is counted as usual.
|
|
658
|
+
const visitorKey = bucketKey(ip);
|
|
659
|
+
const bucket =
|
|
660
|
+
keyOver && !(await activeStore.hasOpenWindow(visitorKey, now, windowMs))
|
|
661
|
+
? undefined
|
|
662
|
+
: await activeStore.hit(visitorKey, now, windowMs);
|
|
663
|
+
const visitorOver = bucket !== undefined && bucket.count > perIpPerMinute;
|
|
664
|
+
const tripped = visitorOver ? bucket : keyOver ? keyBucket : undefined;
|
|
665
|
+
|
|
666
|
+
if (tripped) {
|
|
667
|
+
// Evict stale buckets on every 429 to cap memory under abuse.
|
|
668
|
+
await activeStore.sweep(now, windowMs, "limited");
|
|
474
669
|
}
|
|
475
|
-
|
|
476
|
-
return
|
|
670
|
+
|
|
671
|
+
return { tripped, visitorOver, keyOver };
|
|
477
672
|
}
|
|
478
673
|
|
|
479
674
|
fastify.addHook(
|
|
@@ -483,37 +678,50 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
|
|
|
483
678
|
const { identity: ip, keyIndex } = identityOf(req);
|
|
484
679
|
|
|
485
680
|
requestsSinceLastClean += 1;
|
|
486
|
-
|
|
487
|
-
|
|
681
|
+
const sweepOnInterval = requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS;
|
|
682
|
+
if (sweepOnInterval) requestsSinceLastClean = 0;
|
|
683
|
+
|
|
684
|
+
// #5040 D5 — fail open to the memory store, never to "no limit": a
|
|
685
|
+
// rejection or a timeout on ANY call of the primary store redoes the
|
|
686
|
+
// WHOLE request's counting against the memory store, so one request
|
|
687
|
+
// never mixes stores. withTimeout does not cancel the primary store's
|
|
688
|
+
// in-flight call, so a write that already landed before the failure
|
|
689
|
+
// stays counted, and a timed-out query may still land later — the
|
|
690
|
+
// request can be counted in BOTH stores, over-counting by at most
|
|
691
|
+
// one. That is the safe direction (D5): never under-counts, never
|
|
692
|
+
// lets a request through uncounted.
|
|
693
|
+
let outcome: {
|
|
694
|
+
tripped: RateLimitBucket | undefined;
|
|
695
|
+
visitorOver: boolean;
|
|
696
|
+
keyOver: boolean;
|
|
697
|
+
};
|
|
698
|
+
try {
|
|
699
|
+
outcome = await withTimeout(
|
|
700
|
+
countRequest(primaryStore, ip, keyIndex, now, sweepOnInterval),
|
|
701
|
+
storeTimeoutMs,
|
|
702
|
+
);
|
|
703
|
+
} catch (err) {
|
|
704
|
+
warnFallback(
|
|
705
|
+
(obj, msg) => fastify.log.warn(obj, msg),
|
|
706
|
+
now,
|
|
707
|
+
err instanceof RateLimitStoreTimeoutError ? "timeout" : "error",
|
|
708
|
+
);
|
|
709
|
+
outcome = await countRequest(
|
|
710
|
+
fallbackStore,
|
|
711
|
+
ip,
|
|
712
|
+
keyIndex,
|
|
713
|
+
now,
|
|
714
|
+
sweepOnInterval,
|
|
715
|
+
);
|
|
488
716
|
}
|
|
489
717
|
|
|
490
|
-
|
|
491
|
-
// bucket, even when the visitor bucket is already over its limit.
|
|
492
|
-
const keyBucket =
|
|
493
|
-
keyIndex === null ? undefined : hitKey(`${scope}|key,${keyIndex}`, now);
|
|
494
|
-
const keyOver =
|
|
495
|
-
keyBucket !== undefined && keyBucket.count > perKeyPerMinute;
|
|
496
|
-
// A key over its ceiling opens no NEW visitor bucket (#7394 security
|
|
497
|
-
// review R-1). Each new entry can evict the oldest one in `buckets`,
|
|
498
|
-
// another scope's counter included, so without this a key holder
|
|
499
|
-
// rotating visitor IPs could wipe every strict limiter at will. A
|
|
500
|
-
// visitor whose window is still open is counted as usual.
|
|
501
|
-
const visitorKey = bucketKey(ip);
|
|
502
|
-
const bucket =
|
|
503
|
-
keyOver && !hasOpenWindow(visitorKey, now)
|
|
504
|
-
? undefined
|
|
505
|
-
: hit(visitorKey, now);
|
|
506
|
-
const visitorOver = bucket !== undefined && bucket.count > perIpPerMinute;
|
|
507
|
-
const tripped = visitorOver ? bucket : keyOver ? keyBucket : undefined;
|
|
718
|
+
const { tripped, visitorOver } = outcome;
|
|
508
719
|
|
|
509
720
|
if (tripped) {
|
|
510
721
|
const retryAfterSeconds = Math.ceil(
|
|
511
722
|
(tripped.windowStartMs + windowMs - now) / 1_000,
|
|
512
723
|
);
|
|
513
724
|
|
|
514
|
-
// Evict stale buckets on every 429 to cap memory under abuse.
|
|
515
|
-
evictStaleBuckets(now, windowMs);
|
|
516
|
-
|
|
517
725
|
// The path WITHOUT its query string (#5970 security review, finding 1): a
|
|
518
726
|
// route may carry its credential there (`/v1/requests/:id/confirm?token=…`
|
|
519
727
|
// does), and a rate-limit warning is a line an attacker can provoke at
|
|
@@ -974,6 +974,32 @@ describe("the draft lifecycle (#5036 D1)", () => {
|
|
|
974
974
|
expect(row["resumeTokenHash"]).toBeNull();
|
|
975
975
|
});
|
|
976
976
|
|
|
977
|
+
// #8661 D4 — a listing's slug becomes its diagnostic flow's slug, so the
|
|
978
|
+
// standard magnets' slugs are taken; any other name is unchanged.
|
|
979
|
+
it.each([
|
|
980
|
+
["Contact", "contact-2"],
|
|
981
|
+
["Onboarding", "onboarding-2"],
|
|
982
|
+
["Atlas", "atlas"],
|
|
983
|
+
])("submit: a draft named %s is stored at %s (#8661 D4, AC-2)", async (name, slug) => {
|
|
984
|
+
const { id, resumeToken } = await startDraft(app);
|
|
985
|
+
await app.inject({
|
|
986
|
+
method: "PATCH",
|
|
987
|
+
url: `/v1/project-listings/drafts/${id}`,
|
|
988
|
+
headers: bearer(resumeToken),
|
|
989
|
+
payload: { ...COMPLETE_APPLICATION, name },
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
const res = await app.inject({
|
|
993
|
+
method: "POST",
|
|
994
|
+
url: `/v1/project-listings/drafts/${id}/submit`,
|
|
995
|
+
headers: bearer(resumeToken),
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
expect(res.statusCode).toBe(200);
|
|
999
|
+
expect(res.json()).toEqual({ ok: true, data: { id, slug } });
|
|
1000
|
+
expect(rows.find((r) => r["id"] === id)!["slug"]).toBe(slug);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
977
1003
|
it("submit: a spent token cannot patch or re-submit the listing", async () => {
|
|
978
1004
|
const { id, resumeToken } = await startDraft(app);
|
|
979
1005
|
await app.inject({
|
|
@@ -163,13 +163,41 @@ through `metadata.site`.
|
|
|
163
163
|
open (a session lives up to 24 hours). A session whose stamp no longer
|
|
164
164
|
matches is acked as foreign and never retried, so its hub side effects are
|
|
165
165
|
lost; the `stamp` / `ownSite` pair in the log line is how to spot that drift.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
those
|
|
169
|
-
|
|
166
|
+
A subscription keeps its stamp for its whole life and each finalized
|
|
167
|
+
invoice keeps an immutable copy of it, so a slug collision or a mis-stamp
|
|
168
|
+
on those lasts as long as the subscription — not the 24 hours a session
|
|
169
|
+
lives.
|
|
170
|
+
- **Billing events (#8024).** One gate before the event switch applies the
|
|
171
|
+
same test to every `customer.*`, `customer.subscription.*` and `invoice.*`
|
|
172
|
+
event, handled type or not. It reads the stamp from the event object's own
|
|
173
|
+
metadata — the Subscription's (where spoke-checkout writes `site` through
|
|
174
|
+
`subscription_data.metadata`) or the Customer's — and, for an invoice, from
|
|
175
|
+
its own metadata, then the subscription snapshot under
|
|
176
|
+
`parent.subscription_details.metadata` (the pinned API version), then the
|
|
177
|
+
top-level `subscription_details.metadata` (an endpoint on a pre-`basil`
|
|
178
|
+
version). Any foreign source makes the event foreign; the first is logged.
|
|
179
|
+
A source is read only when `data.object.object` is the family's own type
|
|
180
|
+
(`subscription`, `customer`, `invoice`), so a `customer.source.*` card or a
|
|
181
|
+
`customer.tax_id.*` object is never read. A foreign event answers 200,
|
|
182
|
+
logs `outcome: "foreign_site"` (the same five fields), writes nothing — no
|
|
183
|
+
billing bind, request-paid stamp or dunning record — and is marked seen.
|
|
184
|
+
The decision uses the payload alone: no Stripe call.
|
|
185
|
+
- **What stays unstamped.** The filter cannot see a stamp nobody wrote, so
|
|
186
|
+
these are still handled as the hub's own, exactly as before: spoke
|
|
187
|
+
customers (Checkout cannot put metadata on a customer it creates); quoted
|
|
188
|
+
spoke subscriptions and their invoices (`createQuotedCheckoutSession`
|
|
189
|
+
sends no `subscription_data` yet); a fixed spoke subscription whose `site`
|
|
190
|
+
was later removed; spoke subscriptions created before the stamp shipped,
|
|
191
|
+
or through a Payment Link or the Dashboard; and the billing app's sessions
|
|
192
|
+
and subscriptions on the shared account. Where such a subscription sits on
|
|
193
|
+
a hub User's own Stripe customer (a known buyer reused by a spoke), its
|
|
194
|
+
events keep reaching that User's dunning record.
|
|
170
195
|
- **A label, not a boundary.** The stamp is written by whoever holds the
|
|
171
196
|
account's secret key. It separates cooperating sites on one account, not
|
|
172
|
-
tenants.
|
|
197
|
+
tenants. Whoever holds a write key on the account can also make the hub
|
|
198
|
+
skip one of its own objects' billing events by stamping it with another
|
|
199
|
+
site, which silences the hub's dunning record for that subscription — the
|
|
200
|
+
same key can already cancel or re-price it outright.
|
|
173
201
|
|
|
174
202
|
## Dunning (#6034)
|
|
175
203
|
|