create-ailk 0.11.1 → 0.11.3

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 (105) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/content-payloads.js +2 -2
  4. package/dist/module-architecture.js +11 -0
  5. package/dist/surfaces.d.ts +4 -5
  6. package/dist/surfaces.js +50 -8
  7. package/package.json +3 -3
  8. package/templates/.env.example +42 -3
  9. package/templates/apps/api/.env.example +5 -0
  10. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  11. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  12. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  13. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  14. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  15. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  16. package/templates/apps/api/src/config/index.ts +136 -0
  17. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  18. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  19. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  20. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  21. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  22. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  23. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  25. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  26. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  27. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  28. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  29. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  30. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  31. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  32. package/templates/apps/api/src/middleware/auth.ts +19 -21
  33. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  34. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  35. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  36. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  37. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  38. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  39. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  40. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  41. package/templates/apps/api/src/server.ts +49 -16
  42. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  43. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  44. package/templates/apps/api/src/vercel-handler.ts +58 -12
  45. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  46. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  47. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  48. package/templates/apps/web/.env.example +4 -0
  49. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  50. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  51. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  52. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  53. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  54. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  55. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  58. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  67. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  69. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  70. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  72. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  81. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  82. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  83. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  84. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  85. package/templates/apps/web/app/[locale]/layout.tsx +98 -84
  86. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  87. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  88. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  89. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  90. package/templates/apps/web/auth.ts +102 -18
  91. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  92. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
  93. package/templates/apps/web/jest.config.cjs +5 -0
  94. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  95. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  96. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  97. package/templates/apps/web/lib/account-config.ts +219 -11
  98. package/templates/apps/web/lib/auth-email.ts +104 -2
  99. package/templates/apps/web/lib/auth-env.ts +37 -5
  100. package/templates/apps/web/lib/auth-routes.ts +33 -4
  101. package/templates/apps/web/messages/en.json +147 -21
  102. package/templates/database/CHANGELOG.md +7 -0
  103. package/templates/database/package.json +1 -1
  104. package/versions.json +33 -33
  105. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -45,8 +45,28 @@
45
45
  * call — appending to, not replacing, whatever hop this API's own edge adds,
46
46
  * so trustProxy's leftmost-address resolution still lands on the real
47
47
  * visitor.
48
+ *
49
+ * Per-visitor lead writes from a keyed spoke (#7394): a spoke's lead writes
50
+ * carry the visitor's IP in `x-ailk-visitor-ip`, which this middleware
51
+ * believes only alongside a hub-issued key in `x-ailk-visitor-forward-key`,
52
+ * and only on the three `FORWARDED_VISITOR_ROUTES`. A believed visitor is
53
+ * counted as `v<n>,<ip>` (n = the key's position in
54
+ * `AILK_VISITOR_FORWARD_KEYS`), and every such request also counts against
55
+ * its key's own `key,<n>` bucket. Both forms contain a comma, which `req.ip`
56
+ * never does, so no unkeyed caller can reach either bucket. Every other
57
+ * request keys on `req.ip`, as before. Spec:
58
+ * docs/specs/7394-the-leads-rate-limit-scope-spec.md.
59
+ *
60
+ * Scope namespace and env limit (#7394): a registration with a `scope` keys
61
+ * its buckets as `<scope>|<identity>`, so its count is not shared with any
62
+ * other registration, and reads its limits from
63
+ * `AILK_RATE_LIMIT_<SCOPE>_PER_MINUTE` / `..._PER_KEY_PER_MINUTE`, which win
64
+ * over the code values. A registration with no `scope` behaves as it did.
48
65
  */
49
66
 
67
+ import { createHash, timingSafeEqual } from "node:crypto";
68
+ import { isIP } from "node:net";
69
+
50
70
  import { getClock } from "@working-theory/common";
51
71
  import type {
52
72
  FastifyRequest,
@@ -66,7 +86,11 @@ export type RateLimitOptions = {
66
86
  perIpPerMinute?: number;
67
87
  /** Window duration in milliseconds. Default: 60_000 */
68
88
  windowMs?: number;
69
- /** Extract the IP key from a request. Default: req.ip */
89
+ /**
90
+ * Extract the IP key from a request. Default: the identity
91
+ * `resolveRateLimitIdentity` returns, which is `req.ip` unless
92
+ * `trustedKeyDigests` is non-empty. A caller-supplied function wins.
93
+ */
70
94
  ipFromRequest?: (req: FastifyRequest) => string;
71
95
  /**
72
96
  * Maximum distinct entries `buckets` may hold at once, independent of the
@@ -74,6 +98,25 @@ export type RateLimitOptions = {
74
98
  * no production caller overrides this.
75
99
  */
76
100
  maxBuckets?: number;
101
+ /**
102
+ * Bucket namespace (#7394). With one, buckets are keyed
103
+ * `<scope>|<identity>` and the limits are read from
104
+ * `AILK_RATE_LIMIT_<SCOPE>_PER_MINUTE` and
105
+ * `AILK_RATE_LIMIT_<SCOPE>_PER_KEY_PER_MINUTE` first. Must match
106
+ * `/^[a-z][a-z0-9]*$/`. Without one, buckets are keyed on the bare identity.
107
+ */
108
+ scope?: string;
109
+ /**
110
+ * The hub's forwarding-key digests, from `buildTrustedKeyDigests` (#7394).
111
+ * Default: `[]`, which believes no forwarded visitor. A non-empty list
112
+ * needs a `scope`.
113
+ */
114
+ trustedKeyDigests?: readonly (Buffer | null)[];
115
+ /**
116
+ * Requests allowed per forwarding key per window, across all of that key's
117
+ * visitors (#7394). Default: 300.
118
+ */
119
+ perKeyPerMinute?: number;
77
120
  };
78
121
 
79
122
  type Bucket = {
@@ -98,9 +141,184 @@ export const DEFAULT_PER_IP_PER_MINUTE_STRICT = 10;
98
141
  */
99
142
  export const MAX_BUCKETS = 10_000;
100
143
 
101
- // Module-scoped bucket store. One entry per unique IP.
144
+ /**
145
+ * The only routes on which a forwarded visitor IP is believed (#7394 DD-2):
146
+ * the three lead writes. Matched against `req.routeOptions.url`, the
147
+ * registered route pattern.
148
+ */
149
+ export const FORWARDED_VISITOR_ROUTES: readonly string[] = [
150
+ "/v1/leads/submit",
151
+ "/v1/leads/subscribe",
152
+ "/v1/leads/callback",
153
+ ];
154
+
155
+ const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
156
+ const FORWARD_KEY_HEADER = "x-ailk-visitor-forward-key";
157
+ const MIN_FORWARD_KEY_LENGTH = 32;
158
+ const MAX_VISITOR_IP_LENGTH = 45;
159
+ const DEFAULT_PER_KEY_PER_MINUTE = 300;
160
+ const SCOPE_RE = /^[a-z][a-z0-9]*$/;
161
+ const ENV_LIMIT_RE = /^[1-9][0-9]{0,5}$/;
162
+
163
+ function sha256(value: string): Buffer {
164
+ return createHash("sha256").update(value).digest();
165
+ }
166
+
167
+ /**
168
+ * Parse `AILK_VISITOR_FORWARD_KEYS` (comma-separated, one entry per spoke)
169
+ * into one SHA-256 digest per accepted entry. An empty entry, or one shorter
170
+ * than 32 characters, becomes `null` so every other entry keeps its position.
171
+ * A short entry is reported by its 1-based position, never its value.
172
+ */
173
+ export function buildTrustedKeyDigests(
174
+ raw: string | undefined,
175
+ warn: (msg: string) => void,
176
+ ): readonly (Buffer | null)[] {
177
+ if (raw === undefined || raw.trim() === "") return [];
178
+ return raw.split(",").map((entry, index) => {
179
+ const key = entry.trim();
180
+ if (key === "") return null;
181
+ if (key.length < MIN_FORWARD_KEY_LENGTH) {
182
+ warn(
183
+ `AILK_VISITOR_FORWARD_KEYS entry ${index + 1} has fewer than ${MIN_FORWARD_KEY_LENGTH} characters and is ignored`,
184
+ );
185
+ return null;
186
+ }
187
+ return sha256(key);
188
+ });
189
+ }
190
+
191
+ /** The eight hextets of a valid IPv6 address, or `null`. */
192
+ function expandIpv6(address: string): number[] | null {
193
+ let text = address;
194
+ const tail: number[] = [];
195
+ const lastColon = text.lastIndexOf(":");
196
+ const last = text.slice(lastColon + 1);
197
+ if (last.includes(".")) {
198
+ const octets = last.split(".").map(Number);
199
+ tail.push(((octets[0] ?? 0) << 8) | (octets[1] ?? 0));
200
+ tail.push(((octets[2] ?? 0) << 8) | (octets[3] ?? 0));
201
+ text = `${text.slice(0, lastColon + 1)}0`;
202
+ }
203
+ const halves = text.split("::");
204
+ const parse = (part: string | undefined): number[] =>
205
+ part ? part.split(":").map((hextet) => parseInt(hextet, 16)) : [];
206
+ const head = parse(halves[0]);
207
+ const rest = parse(halves[1]);
208
+ if (tail.length > 0) {
209
+ // The `0` placeholder stood in for the dotted tail's two hextets.
210
+ (halves.length > 1 ? rest : head).pop();
211
+ }
212
+ const fill = 8 - head.length - rest.length - tail.length;
213
+ if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
214
+ const hextets = [
215
+ ...head,
216
+ ...new Array<number>(fill).fill(0),
217
+ ...rest,
218
+ ...tail,
219
+ ];
220
+ return hextets.every((h) => Number.isInteger(h) && h >= 0 && h <= 0xffff)
221
+ ? hextets
222
+ : null;
223
+ }
224
+
225
+ /**
226
+ * Validate and normalise a forwarded visitor IP (#7394 DD-1): IPv4 as is, an
227
+ * IPv4-mapped IPv6 address as its IPv4 form, any other IPv6 address as its
228
+ * /64 prefix. `null` for anything else.
229
+ */
230
+ function normaliseVisitorIp(raw: string): string | null {
231
+ const value = raw.trim().toLowerCase();
232
+ if (value.length > MAX_VISITOR_IP_LENGTH || value.includes("%")) return null;
233
+ const family = isIP(value);
234
+ if (family === 4) return value;
235
+ if (family !== 6) return null;
236
+ const hextets = expandIpv6(value);
237
+ if (!hextets) return null;
238
+ const isV4Mapped =
239
+ hextets.slice(0, 5).every((h) => h === 0) && hextets[5] === 0xffff;
240
+ if (isV4Mapped) {
241
+ const high = hextets[6] ?? 0;
242
+ const low = hextets[7] ?? 0;
243
+ return [high >> 8, high & 0xff, low >> 8, low & 0xff].join(".");
244
+ }
245
+ const prefix = hextets
246
+ .slice(0, 4)
247
+ .map((h) => h.toString(16).padStart(4, "0"))
248
+ .join(":");
249
+ return `${prefix}::/64`;
250
+ }
251
+
252
+ /**
253
+ * The rate-limit identity of a request (#7394 DD-2). A `POST` to one of
254
+ * `FORWARDED_VISITOR_ROUTES`, presenting a key whose digest matches a
255
+ * non-null slot, with a valid `x-ailk-visitor-ip`, is `v<n>,<ip>` where `n`
256
+ * is the first matching slot. Everything else is `req.ip`. Every slot is
257
+ * compared, in constant time per slot, without stopping at the first match.
258
+ */
259
+ export function resolveRateLimitIdentity(
260
+ req: FastifyRequest,
261
+ trustedKeyDigests: readonly (Buffer | null)[],
262
+ ): { identity: string; keyIndex: number | null } {
263
+ const fallback = { identity: req.ip, keyIndex: null };
264
+ if (!trustedKeyDigests.some((digest) => digest !== null)) return fallback;
265
+ if (req.method !== "POST") return fallback;
266
+ if (!FORWARDED_VISITOR_ROUTES.includes(req.routeOptions.url ?? "")) {
267
+ return fallback;
268
+ }
269
+
270
+ const presented = req.headers[FORWARD_KEY_HEADER];
271
+ if (typeof presented !== "string" || presented === "") return fallback;
272
+ const presentedDigest = sha256(presented);
273
+ let keyIndex: number | null = null;
274
+ for (const [index, digest] of trustedKeyDigests.entries()) {
275
+ if (digest === null) continue;
276
+ const matched = timingSafeEqual(digest, presentedDigest);
277
+ if (matched && keyIndex === null) keyIndex = index;
278
+ }
279
+ if (keyIndex === null) return fallback;
280
+
281
+ const visitorIp = req.headers[VISITOR_IP_HEADER];
282
+ const normalised =
283
+ typeof visitorIp === "string" ? normaliseVisitorIp(visitorIp) : null;
284
+ if (normalised === null) return fallback;
285
+ return { identity: `v${keyIndex},${normalised}`, keyIndex };
286
+ }
287
+
288
+ /**
289
+ * A named scope's limit (#7394 DD-4): `AILK_RATE_LIMIT_<SCOPE>_<suffix>` when
290
+ * it is a whole number from 1 to 999999, else `fallback`. A set but invalid
291
+ * value is reported once and ignored. An unnamed registration always gets
292
+ * `fallback`.
293
+ */
294
+ function resolveScopedLimit(
295
+ scope: string | undefined,
296
+ suffix: "PER_MINUTE" | "PER_KEY_PER_MINUTE",
297
+ fallback: number,
298
+ warn: (msg: string) => void,
299
+ ): number {
300
+ if (scope === undefined) return fallback;
301
+ const name = `AILK_RATE_LIMIT_${scope.toUpperCase()}_${suffix}`;
302
+ const raw = process.env[name];
303
+ if (raw === undefined || raw === "") return fallback;
304
+ if (ENV_LIMIT_RE.test(raw)) return Number(raw);
305
+ warn(
306
+ `${name} is not a whole number from 1 to 999999 and is ignored; using ${fallback}`,
307
+ );
308
+ return fallback;
309
+ }
310
+
311
+ // Module-scoped bucket store. One entry per unique identity, per named scope
312
+ // (#7394). Per-forwarding-key buckets live in `keyBuckets` below.
102
313
  const buckets = new Map<string, Bucket>();
103
314
 
315
+ // Per-forwarding-key buckets (#7394 DD-8), kept apart from `buckets` so the
316
+ // `maxBuckets` FIFO eviction can never drop one: a keyed caller churning
317
+ // visitor IPs would otherwise push its own key bucket out and reset its
318
+ // ceiling. At most one entry per configured key per named scope, so it
319
+ // needs no capacity cap of its own.
320
+ const keyBuckets = new Map<string, Bucket>();
321
+
104
322
  // Request counter used to trigger periodic cleanup.
105
323
  let requestsSinceLastClean = 0;
106
324
  const CLEAN_INTERVAL_REQUESTS = 100;
@@ -111,6 +329,11 @@ function evictStaleBuckets(now: number, windowMs: number): void {
111
329
  buckets.delete(ip);
112
330
  }
113
331
  }
332
+ for (const [key, bucket] of keyBuckets) {
333
+ if (now - bucket.windowStartMs >= windowMs) {
334
+ keyBuckets.delete(key);
335
+ }
336
+ }
114
337
  requestsSinceLastClean = 0;
115
338
  }
116
339
 
@@ -120,6 +343,7 @@ function evictStaleBuckets(now: number, windowMs: number): void {
120
343
  */
121
344
  export function __resetBuckets(): void {
122
345
  buckets.clear();
346
+ keyBuckets.clear();
123
347
  requestsSinceLastClean = 0;
124
348
  }
125
349
 
@@ -141,47 +365,123 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
141
365
  done,
142
366
  ) => {
143
367
  const {
144
- perIpPerMinute = Number(process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE) ||
145
- 20,
146
368
  windowMs = 60_000,
147
- ipFromRequest = (req: FastifyRequest) => req.ip,
148
369
  maxBuckets = MAX_BUCKETS,
370
+ scope,
371
+ trustedKeyDigests = [],
149
372
  } = opts;
150
373
 
374
+ if (scope !== undefined && !SCOPE_RE.test(scope)) {
375
+ done(
376
+ new Error(
377
+ `rate-limit scope ${JSON.stringify(scope)} must match ${SCOPE_RE}`,
378
+ ),
379
+ );
380
+ return;
381
+ }
382
+ if (trustedKeyDigests.length > 0 && scope === undefined) {
383
+ done(new Error("rate-limit trustedKeyDigests needs a scope"));
384
+ return;
385
+ }
386
+
387
+ const warn = (msg: string) => fastify.log.warn(msg);
388
+ const perIpPerMinute = resolveScopedLimit(
389
+ scope,
390
+ "PER_MINUTE",
391
+ opts.perIpPerMinute ??
392
+ (Number(process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE) || 20),
393
+ warn,
394
+ );
395
+ const perKeyPerMinute = resolveScopedLimit(
396
+ scope,
397
+ "PER_KEY_PER_MINUTE",
398
+ opts.perKeyPerMinute ?? DEFAULT_PER_KEY_PER_MINUTE,
399
+ warn,
400
+ );
401
+ const { ipFromRequest } = opts;
402
+ const identityOf: (req: FastifyRequest) => {
403
+ identity: string;
404
+ keyIndex: number | null;
405
+ } = ipFromRequest
406
+ ? (req) => ({ identity: ipFromRequest(req), keyIndex: null })
407
+ : (req) => resolveRateLimitIdentity(req, trustedKeyDigests);
408
+ const bucketKey = (identity: string) =>
409
+ scope === undefined ? identity : `${scope}|${identity}`;
410
+
411
+ /** Count one request against `key`, opening a new window when needed. */
412
+ function hit(key: string, now: number): Bucket {
413
+ let bucket = buckets.get(key);
414
+
415
+ if (!bucket || now - bucket.windowStartMs >= windowMs) {
416
+ // A genuinely new distinct identity (not a window roll on an
417
+ // existing bucket) at capacity evicts the single oldest-inserted
418
+ // entry first — independent of the time-based eviction above,
419
+ // which only removes EXPIRED windows and may leave `buckets` at or
420
+ // above `maxBuckets` when every entry is still within its window.
421
+ if (!buckets.has(key) && buckets.size >= maxBuckets) {
422
+ const oldestKey = buckets.keys().next().value;
423
+ if (oldestKey !== undefined) {
424
+ buckets.delete(oldestKey);
425
+ }
426
+ }
427
+ bucket = { count: 0, windowStartMs: now };
428
+ buckets.set(key, bucket);
429
+ }
430
+
431
+ bucket.count += 1;
432
+ return bucket;
433
+ }
434
+
435
+ /** True when `key` has a bucket whose window has not yet ended. */
436
+ function hasOpenWindow(key: string, now: number): boolean {
437
+ const bucket = buckets.get(key);
438
+ return bucket !== undefined && now - bucket.windowStartMs < windowMs;
439
+ }
440
+
441
+ /** Count one request against a forwarding key's own bucket (DD-8). */
442
+ function hitKey(key: string, now: number): Bucket {
443
+ let bucket = keyBuckets.get(key);
444
+ if (!bucket || now - bucket.windowStartMs >= windowMs) {
445
+ bucket = { count: 0, windowStartMs: now };
446
+ keyBuckets.set(key, bucket);
447
+ }
448
+ bucket.count += 1;
449
+ return bucket;
450
+ }
451
+
151
452
  fastify.addHook(
152
453
  "onRequest",
153
454
  async (req: FastifyRequest, reply: FastifyReply) => {
154
455
  const now = getClock().now();
155
- const ip = ipFromRequest(req);
456
+ const { identity: ip, keyIndex } = identityOf(req);
156
457
 
157
458
  requestsSinceLastClean += 1;
158
459
  if (requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS) {
159
460
  evictStaleBuckets(now, windowMs);
160
461
  }
161
462
 
162
- let bucket = buckets.get(ip);
163
-
164
- if (!bucket || now - bucket.windowStartMs >= windowMs) {
165
- // A genuinely new distinct identity (not a window roll on an
166
- // existing bucket) at capacity evicts the single oldest-inserted
167
- // entry first — independent of the time-based eviction above,
168
- // which only removes EXPIRED windows and may leave `buckets` at or
169
- // above `maxBuckets` when every entry is still within its window.
170
- if (!buckets.has(ip) && buckets.size >= maxBuckets) {
171
- const oldestKey = buckets.keys().next().value;
172
- if (oldestKey !== undefined) {
173
- buckets.delete(oldestKey);
174
- }
175
- }
176
- bucket = { count: 0, windowStartMs: now };
177
- buckets.set(ip, bucket);
178
- }
463
+ // #7394 DD-8: a keyed request also counts against its key's own
464
+ // bucket, even when the visitor bucket is already over its limit.
465
+ const keyBucket =
466
+ keyIndex === null ? undefined : hitKey(`${scope}|key,${keyIndex}`, now);
467
+ const keyOver =
468
+ keyBucket !== undefined && keyBucket.count > perKeyPerMinute;
469
+ // A key over its ceiling opens no NEW visitor bucket (#7394 security
470
+ // review R-1). Each new entry can evict the oldest one in `buckets`,
471
+ // another scope's counter included, so without this a key holder
472
+ // rotating visitor IPs could wipe every strict limiter at will. A
473
+ // visitor whose window is still open is counted as usual.
474
+ const visitorKey = bucketKey(ip);
475
+ const bucket =
476
+ keyOver && !hasOpenWindow(visitorKey, now)
477
+ ? undefined
478
+ : hit(visitorKey, now);
479
+ const visitorOver = bucket !== undefined && bucket.count > perIpPerMinute;
480
+ const tripped = visitorOver ? bucket : keyOver ? keyBucket : undefined;
179
481
 
180
- bucket.count += 1;
181
-
182
- if (bucket.count > perIpPerMinute) {
482
+ if (tripped) {
183
483
  const retryAfterSeconds = Math.ceil(
184
- (bucket.windowStartMs + windowMs - now) / 1_000,
484
+ (tripped.windowStartMs + windowMs - now) / 1_000,
185
485
  );
186
486
 
187
487
  // Evict stale buckets on every 429 to cap memory under abuse.
@@ -193,7 +493,20 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
193
493
  // will — logging the raw target would let them farm credentials out of
194
494
  // the operator's own log by tripping the limiter.
195
495
  const pathOnly = req.url.split("?")[0] ?? req.url;
196
- req.log.warn({ ip, path: pathOnly, count: bucket.count }, RATE_LIMITED);
496
+ // A keyed 429 names the bucket that tripped and the key's position,
497
+ // so a misused key can be traced to its spoke; never the key itself.
498
+ req.log.warn(
499
+ {
500
+ ip,
501
+ path: pathOnly,
502
+ count: tripped.count,
503
+ ...(keyIndex !== null && {
504
+ bucket: visitorOver ? "visitor" : "key",
505
+ keyIndex,
506
+ }),
507
+ },
508
+ RATE_LIMITED,
509
+ );
197
510
 
198
511
  return reply
199
512
  .status(429)
@@ -42,7 +42,7 @@ const FRONTEND = "https://app.example.com";
42
42
 
43
43
  async function buildApp(): Promise<FastifyInstance> {
44
44
  const app = Fastify();
45
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
45
+ app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
46
46
  app.register(tokenRoutes);
47
47
  await app.ready();
48
48
  return app;
@@ -37,7 +37,7 @@ const FRONTEND = "https://app.example.com";
37
37
 
38
38
  async function buildApp(): Promise<FastifyInstance> {
39
39
  const app = Fastify();
40
- app.register(authPreHandlerPlugin, { frontendOrigin: FRONTEND });
40
+ app.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
41
41
  app.register(userRoutes);
42
42
  await app.ready();
43
43
  return app;
@@ -29,6 +29,7 @@ import type { FastifyInstance } from "fastify";
29
29
  const mockSendPasswordReset = jest.fn();
30
30
  const mockSendPasswordChanged = jest.fn();
31
31
  const mockSendEmailVerification = jest.fn();
32
+ const mockSendMagicLink = jest.fn();
32
33
 
33
34
  jest.mock("@working-theory/email", () => ({
34
35
  sendPasswordResetEmail: (...args: unknown[]) =>
@@ -37,6 +38,7 @@ jest.mock("@working-theory/email", () => ({
37
38
  mockSendPasswordChanged(...args),
38
39
  sendEmailVerificationEmail: (...args: unknown[]) =>
39
40
  mockSendEmailVerification(...args),
41
+ sendMagicLink: (...args: unknown[]) => mockSendMagicLink(...args),
40
42
  }));
41
43
 
42
44
  import { __resetAuthEmailBudget } from "../../../lib/auth-email-budget.js";
@@ -62,6 +64,18 @@ function bodyFor(overrides: Record<string, unknown> = {}): string {
62
64
  });
63
65
  }
64
66
 
67
+ const MAGIC_LINK_URL =
68
+ "https://site.example/api/auth/magic-link/verify?token=mltok&callbackURL=%2Faccount";
69
+
70
+ function magicLinkBody(): string {
71
+ return JSON.stringify({
72
+ kind: "magic-link",
73
+ userId: "user_01",
74
+ to: "person@example.com",
75
+ url: MAGIC_LINK_URL,
76
+ });
77
+ }
78
+
65
79
  async function buildApp(): Promise<FastifyInstance> {
66
80
  const app = Fastify();
67
81
  // The raw-body parser the route's scope carries in server.ts — the
@@ -108,6 +122,7 @@ beforeEach(async () => {
108
122
  mockSendPasswordReset.mockResolvedValue({ ok: true, messageId: "m1" });
109
123
  mockSendPasswordChanged.mockResolvedValue({ ok: true, messageId: "m2" });
110
124
  mockSendEmailVerification.mockResolvedValue({ ok: true, messageId: "m3" });
125
+ mockSendMagicLink.mockResolvedValue({ ok: true, messageId: "m4" });
111
126
  app = await buildApp();
112
127
  });
113
128
 
@@ -185,7 +200,7 @@ describe("POST /v1/auth/transactional-email — the gate", () => {
185
200
  expect(res.json()).toEqual({ error: "validation_failed" });
186
201
  });
187
202
 
188
- it("answers 400 for a kind outside the fixed three", async () => {
203
+ it("answers 400 for a kind outside the fixed four", async () => {
189
204
  const res = await post(app, {
190
205
  rawBody: bodyFor({ kind: "arbitrary-marketing-blast" }),
191
206
  });
@@ -196,6 +211,18 @@ describe("POST /v1/auth/transactional-email — the gate", () => {
196
211
  const res = await post(app, { rawBody: bodyFor({ url: undefined }) });
197
212
  expect(res.statusCode).toBe(400);
198
213
  });
214
+
215
+ it("answers 400 for a magic-link with no url (#7435 AC-4)", async () => {
216
+ const res = await post(app, {
217
+ rawBody: bodyFor({
218
+ kind: "magic-link",
219
+ url: undefined,
220
+ expiresInMinutes: undefined,
221
+ }),
222
+ });
223
+ expect(res.statusCode).toBe(400);
224
+ expect(mockSendMagicLink).not.toHaveBeenCalled();
225
+ });
199
226
  });
200
227
 
201
228
  describe("POST /v1/auth/transactional-email — the sends", () => {
@@ -238,6 +265,36 @@ describe("POST /v1/auth/transactional-email — the sends", () => {
238
265
  expect(mockSendPasswordReset).not.toHaveBeenCalled();
239
266
  });
240
267
 
268
+ it("sends a magic-link through the magic-link template with no expiry override (#7435 D4)", async () => {
269
+ const res = await post(app, { rawBody: magicLinkBody() });
270
+ expect(res.statusCode).toBe(202);
271
+ expect(res.json()).toEqual({ ok: true });
272
+ expect(mockSendMagicLink).toHaveBeenCalledTimes(1);
273
+ const [input] = mockSendMagicLink.mock.calls[0];
274
+ expect(input).toEqual({
275
+ to: "person@example.com",
276
+ url: MAGIC_LINK_URL,
277
+ fromAddress: FROM,
278
+ });
279
+ // A sign-in link must never arrive dressed as a reset or a verification.
280
+ expect(mockSendPasswordReset).not.toHaveBeenCalled();
281
+ expect(mockSendEmailVerification).not.toHaveBeenCalled();
282
+ });
283
+
284
+ it("budgets magic-link per recipient on its own key — three sends, four 202s, reset unaffected (#7435 AC-3)", async () => {
285
+ for (let i = 0; i < 4; i++) {
286
+ const r = await post(app, { rawBody: magicLinkBody() });
287
+ expect(r.statusCode).toBe(202);
288
+ expect(r.json()).toEqual({ ok: true });
289
+ }
290
+ expect(mockSendMagicLink).toHaveBeenCalledTimes(3);
291
+
292
+ // The magic-link budget neither consumes nor is consumed by reset's.
293
+ const reset = await post(app);
294
+ expect(reset.statusCode).toBe(202);
295
+ expect(mockSendPasswordReset).toHaveBeenCalledTimes(1);
296
+ });
297
+
241
298
  it("answers 202 even when the sender reports a failure — never an oracle", async () => {
242
299
  mockSendPasswordReset.mockResolvedValue({
243
300
  ok: false,
@@ -306,4 +363,51 @@ describe("the PII boundary (§ D12)", () => {
306
363
  expect(joined).not.toContain("site.example/api/auth");
307
364
  await loggingApp.close();
308
365
  });
366
+
367
+ it("logs no recipient, no token and no link for a magic-link send (#7435)", async () => {
368
+ const lines: string[] = [];
369
+ const capture = new Proxy(
370
+ {},
371
+ {
372
+ get: () => (obj: unknown, msg?: unknown) =>
373
+ lines.push(JSON.stringify({ obj, msg })),
374
+ },
375
+ ) as never;
376
+
377
+ const loggingApp = Fastify({ logger: false });
378
+ loggingApp.addContentTypeParser(
379
+ "application/json",
380
+ { parseAs: "buffer" },
381
+ (_req, body, done) => done(null, body),
382
+ );
383
+ loggingApp.addHook("onRequest", async (req) => {
384
+ // eslint-disable-next-line no-restricted-syntax -- the point of this case is to capture what the handler LOGS, and Fastify types `req.log` as a concrete Logger; there is no boundary type to fix.
385
+ (req as unknown as { log: unknown }).log = capture;
386
+ });
387
+ await loggingApp.register(transactionalEmailRoute);
388
+ await loggingApp.ready();
389
+
390
+ // Four sends: three logged as sent, the fourth as budget-suppressed.
391
+ for (let i = 0; i < 4; i++) {
392
+ const ts = String(Math.floor(Date.now() / 1000));
393
+ const raw = magicLinkBody();
394
+ await loggingApp.inject({
395
+ method: "POST",
396
+ url: "/v1/auth/transactional-email",
397
+ headers: {
398
+ "content-type": "application/json",
399
+ "x-ailk-auth-email-signature": sign(raw, ts),
400
+ "x-ailk-auth-email-timestamp": ts,
401
+ },
402
+ payload: raw,
403
+ });
404
+ }
405
+
406
+ const joined = lines.join("|");
407
+ expect(joined).toContain("magic-link");
408
+ expect(joined).not.toContain("person@example.com");
409
+ expect(joined).not.toContain("mltok");
410
+ expect(joined).not.toContain("magic-link/verify");
411
+ await loggingApp.close();
412
+ });
309
413
  });