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.
Files changed (199) hide show
  1. package/README.md +84 -7
  2. package/component-catalog.json +184 -6
  3. package/component-catalog.md +20 -9
  4. package/dist/apply-design.d.ts +9 -0
  5. package/dist/apply-design.js +78 -31
  6. package/dist/cli.js +35 -0
  7. package/dist/copy.d.ts +41 -0
  8. package/dist/copy.js +87 -21
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/lib/design-checks.d.ts +6 -1
  12. package/dist/lib/design-checks.js +23 -2
  13. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  14. package/dist/lib/fetch-template-snapshot.js +205 -0
  15. package/dist/lib/merge-three-way.d.ts +31 -0
  16. package/dist/lib/merge-three-way.js +65 -0
  17. package/dist/module-architecture.js +55 -2
  18. package/dist/programmatic.d.ts +7 -1
  19. package/dist/programmatic.js +76 -22
  20. package/dist/site-roster.d.ts +4 -2
  21. package/dist/site-roster.js +7 -5
  22. package/dist/spoke-checkout.d.ts +7 -0
  23. package/dist/spoke-checkout.js +8 -1
  24. package/dist/starter-grid.js +16 -0
  25. package/dist/surfaces.js +78 -2
  26. package/dist/sync-routes.d.ts +28 -0
  27. package/dist/sync-routes.js +123 -1
  28. package/dist/template-record.d.ts +59 -0
  29. package/dist/template-record.js +183 -0
  30. package/dist/update.d.ts +53 -0
  31. package/dist/update.js +606 -0
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  51. package/templates/.claude/hooks/architecture-check.sh +8 -0
  52. package/templates/.claude/rules/loop-contract.md +129 -0
  53. package/templates/.claude/settings.json +3 -2
  54. package/templates/.env.example +13 -0
  55. package/templates/CLAUDE.md +1 -1
  56. package/templates/CONVENTIONS.md +3 -2
  57. package/templates/README.md +26 -25
  58. package/templates/_gitignore +162 -0
  59. package/templates/apps/api/CLAUDE.md +18 -10
  60. package/templates/apps/api/jest.config.cjs +1 -0
  61. package/templates/apps/api/package.json +2 -1
  62. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
  63. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  64. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  65. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  66. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  67. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  68. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  69. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
  70. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  71. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  72. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  73. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
  74. package/templates/apps/api/src/middleware/auth.ts +8 -4
  75. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  76. package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
  77. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  78. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  79. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  80. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  81. package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
  82. package/templates/apps/api/src/server.ts +14 -2
  83. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  84. package/templates/apps/api/src/services/audit-log.ts +72 -10
  85. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  86. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  87. package/templates/apps/api/src/services/project-listings.ts +6 -1
  88. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  89. package/templates/apps/api/vercel.json +1 -1
  90. package/templates/apps/mcp/CLAUDE.md +19 -7
  91. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +139 -44
  92. package/templates/apps/mcp/jest.config.cjs +1 -0
  93. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  94. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  95. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  96. package/templates/apps/web/CLAUDE.md +26 -1
  97. package/templates/apps/web/_gitignore +6 -0
  98. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  99. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  100. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
  111. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  115. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  118. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
  124. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
  125. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  129. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
  132. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  133. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  134. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  135. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +2 -2
  136. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  137. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  138. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  139. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +7 -0
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +347 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +73 -0
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +5 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +48 -4
  148. package/templates/apps/web/app/[locale]/contact/page.tsx +81 -9
  149. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  150. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  151. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/layout.tsx +46 -4
  154. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  155. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  156. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  157. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  158. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  160. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  161. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  162. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  163. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  164. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  165. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  166. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  167. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  168. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  169. package/templates/apps/web/jest.config.cjs +4 -0
  170. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  171. package/templates/apps/web/lib/__tests__/deck-page.test.ts +244 -0
  172. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  173. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +100 -9
  174. package/templates/apps/web/lib/account-config.ts +31 -16
  175. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  176. package/templates/apps/web/lib/deck-page.ts +186 -0
  177. package/templates/apps/web/lib/relay.ts +20 -0
  178. package/templates/apps/web/messages/en.json +83 -9
  179. package/templates/apps/web/next.config.mjs +8 -2
  180. package/templates/database/CHANGELOG.md +48 -0
  181. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  182. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/__tests__/seed.test.ts +39 -2
  186. package/templates/database/_gitignore +8 -0
  187. package/templates/database/inbox/schema.prisma +38 -12
  188. package/templates/database/jest.config.cjs +1 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/database/seed.ts +29 -0
  196. package/templates/project.yaml +6 -3
  197. package/templates/tools/jest/pin-timezone.cjs +34 -0
  198. package/versions.json +35 -35
  199. 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: This module uses an in-process Map to store request
5
- * counts. Each Fastify instance maintains its own bucket state. In a
6
- * multi-replica deployment (e.g. 3 Vercel functions), an attacker can issue
7
- * up to N × perIpPerMinute requests per minute per IP, where N is the replica
8
- * count. For OSS v1 (single-replica), this is acceptable. A Redis-backed swap
9
- * is tracked in the follow-up issue "Phase 6+ — Redis-backed rate limit for
10
- * multi-replica deploys."
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`. The
55
- * global limiter (`server.ts`) passes the hub's digests, so a listed call is
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
- type Bucket = {
129
- count: number;
130
- windowStartMs: number;
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, and the two schedule calls a site's
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). Matched against `req.method` and `req.routeOptions.url`, the
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. One entry per unique identity, per named scope
339
- // (#7394). Per-forwarding-key buckets live in `keyBuckets` below.
340
- const buckets = new Map<string, Bucket>();
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, Bucket>();
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
- /** Count one request against `key`, opening a new window when needed. */
439
- function hit(key: string, now: number): Bucket {
440
- let bucket = buckets.get(key);
621
+ const fallbackStore = memoryRateLimitStore(maxBuckets);
622
+ const primaryStore: RateLimitStore =
623
+ opts.store ?? resolveProcessDefaultStore(warn, fastify.log, maxBuckets);
441
624
 
442
- if (!bucket || now - bucket.windowStartMs >= windowMs) {
443
- // A genuinely new distinct identity (not a window roll on an
444
- // existing bucket) at capacity evicts the single oldest-inserted
445
- // entry first — independent of the time-based eviction above,
446
- // which only removes EXPIRED windows and may leave `buckets` at or
447
- // above `maxBuckets` when every entry is still within its window.
448
- if (!buckets.has(key) && buckets.size >= maxBuckets) {
449
- const oldestKey = buckets.keys().next().value;
450
- if (oldestKey !== undefined) {
451
- buckets.delete(oldestKey);
452
- }
453
- }
454
- bucket = { count: 0, windowStartMs: now };
455
- buckets.set(key, bucket);
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
- bucket.count += 1;
459
- return bucket;
460
- }
461
-
462
- /** True when `key` has a bucket whose window has not yet ended. */
463
- function hasOpenWindow(key: string, now: number): boolean {
464
- const bucket = buckets.get(key);
465
- return bucket !== undefined && now - bucket.windowStartMs < windowMs;
466
- }
467
-
468
- /** Count one request against a forwarding key's own bucket (DD-8). */
469
- function hitKey(key: string, now: number): Bucket {
470
- let bucket = keyBuckets.get(key);
471
- if (!bucket || now - bucket.windowStartMs >= windowMs) {
472
- bucket = { count: 0, windowStartMs: now };
473
- keyBuckets.set(key, bucket);
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
- bucket.count += 1;
476
- return bucket;
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
- if (requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS) {
487
- evictStaleBuckets(now, windowMs);
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
- // #7394 DD-8: a keyed request also counts against its key's own
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
- - **Not filtered.** `customer.*`, `invoice.*` and subscription events carry no
167
- session metadata and are handled as before. Filtering them needs a stamp on
168
- those objects, which the spoke-checkout package writes (spoke pattern
169
- Phase 3).
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