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.
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +42 -3
- package/templates/apps/api/.env.example +5 -0
- package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/server.test.ts +5 -1
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
- package/templates/apps/api/src/config/index.ts +136 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
- package/templates/apps/api/src/server.ts +49 -16
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +98 -84
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +102 -18
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +147 -21
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- 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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
if (bucket.count > perIpPerMinute) {
|
|
482
|
+
if (tripped) {
|
|
183
483
|
const retryAfterSeconds = Math.ceil(
|
|
184
|
-
(
|
|
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
|
-
|
|
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, {
|
|
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, {
|
|
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
|
|
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
|
});
|