@tuturuuu/utils 0.7.0 → 0.9.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.
@@ -35,6 +35,8 @@ export const ABUSE_THRESHOLDS = {
35
35
  // Password login limits
36
36
  PASSWORD_LOGIN_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
37
37
  PASSWORD_LOGIN_FAILED_MAX: 10,
38
+ PASSWORD_LOGIN_FAILED_EMAIL_WINDOW_MS: 15 * 60 * 1000, // 15 minutes
39
+ PASSWORD_LOGIN_FAILED_EMAIL_MAX: 10,
38
40
 
39
41
  // API auth failure limits (session-auth routes)
40
42
  API_AUTH_FAILED_WINDOW_MS: 5 * 60 * 1000, // 5 minutes
@@ -95,6 +97,8 @@ export const REDIS_KEYS = {
95
97
 
96
98
  // Password login attempts
97
99
  PASSWORD_LOGIN_FAILED: (ip: string) => `password:login:failed:${ip}`,
100
+ PASSWORD_LOGIN_FAILED_EMAIL: (emailHash: string) =>
101
+ `password:login:failed:email:${emailHash}`,
98
102
 
99
103
  // API auth failure attempts (session-auth routes)
100
104
  API_AUTH_FAILED: (ip: string) => `api:auth:failed:${ip}`,
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Edge-compatible trust multiplier cache.
3
+ *
4
+ * Bridges the server-side abuse reputation/trust system (Postgres) to the edge
5
+ * proxy guard so that read rate limits can be scaled up for trusted accounts
6
+ * and trusted locations (CIDRs) without a per-request database round-trip.
7
+ *
8
+ * Like `edge.ts`, this module only relies on the @upstash/redis REST client and
9
+ * is safe to run in Vercel Edge Runtime / Next.js proxy middleware as well as in
10
+ * the Node serverless layer (used by the write-through path in reputation.ts).
11
+ *
12
+ * Trust can never be forged from the edge: cache entries are only written by the
13
+ * service-role write-through path keyed on hashes of real, observed subjects. A
14
+ * fabricated subject key simply misses the cache and resolves to a 1.0
15
+ * (neutral) multiplier — fail-open by design.
16
+ */
17
+
18
+ import {
19
+ getUpstashRestRedisClient,
20
+ type UpstashRestRedisClient,
21
+ } from '../upstash-rest';
22
+
23
+ const TRUST_CACHE_PREFIX = 'trust:mult';
24
+
25
+ // Mirrors the DB CHECK constraint on trust_multiplier (> 0 AND <= 1000).
26
+ const MIN_TRUST_MULTIPLIER = 0.35;
27
+ const MAX_TRUST_MULTIPLIER = 1000;
28
+ const NEUTRAL_TRUST_MULTIPLIER = 1;
29
+
30
+ /**
31
+ * A cached rate-limit decision for a subject as seen by the edge proxy. Stored
32
+ * as a plain number for the common multiplier-only case (back-compat), or as a
33
+ * JSON object when an admin rule carries a richer mode.
34
+ */
35
+ export interface CachedTrustEntry {
36
+ /** Read-limit multiplier (uplift). 1 = neutral. Ignored when mode=unlimited. */
37
+ m: number;
38
+ /** Admin rule mode. Absent = plain multiplier. */
39
+ mode?: 'absolute' | 'unlimited';
40
+ /** For mode=absolute: explicit per-window READ limits. */
41
+ abs?: { minute?: number; hour?: number; day?: number };
42
+ /** Server-verified session marker. Does not increase limits by itself. */
43
+ verified?: boolean;
44
+ }
45
+
46
+ const DEFAULT_TRUST_CACHE_TTL_SECONDS = 3600; // 1 hour
47
+
48
+ let trustRedisClient: UpstashRestRedisClient | null = null;
49
+ let trustRedisInitialized = false;
50
+
51
+ function parsePositiveIntEnv(name: string, fallback: number): number {
52
+ const rawValue = process.env[name];
53
+ if (!rawValue) {
54
+ return fallback;
55
+ }
56
+
57
+ const parsed = Number.parseInt(rawValue, 10);
58
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
59
+ }
60
+
61
+ export function getTrustCacheTtlSeconds(): number {
62
+ return parsePositiveIntEnv(
63
+ 'EDGE_TRUST_CACHE_TTL_SECONDS',
64
+ DEFAULT_TRUST_CACHE_TTL_SECONDS
65
+ );
66
+ }
67
+
68
+ async function getTrustRedisClient() {
69
+ if (trustRedisInitialized) return trustRedisClient;
70
+
71
+ try {
72
+ trustRedisClient = await getUpstashRestRedisClient();
73
+ trustRedisInitialized = true;
74
+ return trustRedisClient;
75
+ } catch {
76
+ trustRedisInitialized = true;
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Maps an abuse reputation subject key (e.g. `user:<id>`, `session:<hash>`,
83
+ * `cidr:1.2.3.0/24`) to its trust-cache Redis key.
84
+ */
85
+ export function buildTrustCacheKey(subjectKey: string): string {
86
+ return `${TRUST_CACHE_PREFIX}:${subjectKey}`;
87
+ }
88
+
89
+ function clampMultiplier(value: number): number {
90
+ if (!Number.isFinite(value) || value <= 0) {
91
+ return NEUTRAL_TRUST_MULTIPLIER;
92
+ }
93
+
94
+ return Math.min(MAX_TRUST_MULTIPLIER, Math.max(MIN_TRUST_MULTIPLIER, value));
95
+ }
96
+
97
+ function parseMultiplier(value: unknown): number | null {
98
+ if (typeof value === 'number') {
99
+ return Number.isFinite(value) ? value : null;
100
+ }
101
+
102
+ if (typeof value === 'string') {
103
+ const parsed = Number.parseFloat(value);
104
+ return Number.isFinite(parsed) ? parsed : null;
105
+ }
106
+
107
+ return null;
108
+ }
109
+
110
+ function parseAbsLimits(value: unknown): CachedTrustEntry['abs'] | undefined {
111
+ if (!value || typeof value !== 'object') {
112
+ return undefined;
113
+ }
114
+
115
+ const source = value as Record<string, unknown>;
116
+ const abs: { minute?: number; hour?: number; day?: number } = {};
117
+ for (const window of ['minute', 'hour', 'day'] as const) {
118
+ const parsed = parseMultiplier(source[window]);
119
+ if (parsed != null && parsed > 0) {
120
+ abs[window] = Math.floor(parsed);
121
+ }
122
+ }
123
+
124
+ return abs.minute != null || abs.hour != null || abs.day != null
125
+ ? abs
126
+ : undefined;
127
+ }
128
+
129
+ /**
130
+ * Parses a cached value into a {@link CachedTrustEntry}, accepting BOTH legacy
131
+ * plain numbers/numeric strings (back-compat) and the richer JSON object form.
132
+ */
133
+ function parseEntry(value: unknown): CachedTrustEntry | null {
134
+ const asNumber = parseMultiplier(value);
135
+ if (asNumber != null) {
136
+ return { m: clampMultiplier(asNumber) };
137
+ }
138
+
139
+ let source: unknown = value;
140
+ if (typeof value === 'string') {
141
+ try {
142
+ source = JSON.parse(value);
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ if (!source || typeof source !== 'object') {
149
+ return null;
150
+ }
151
+
152
+ const record = source as Record<string, unknown>;
153
+ const m = clampMultiplier(
154
+ parseMultiplier(record.m) ?? NEUTRAL_TRUST_MULTIPLIER
155
+ );
156
+ const mode =
157
+ record.mode === 'absolute' || record.mode === 'unlimited'
158
+ ? record.mode
159
+ : undefined;
160
+ const abs = parseAbsLimits(record.abs);
161
+ const verified = record.verified === true;
162
+
163
+ const entry: CachedTrustEntry = { m };
164
+ if (mode) {
165
+ entry.mode = mode;
166
+ }
167
+ if (abs) {
168
+ entry.abs = abs;
169
+ }
170
+ if (verified) {
171
+ entry.verified = true;
172
+ }
173
+ return entry;
174
+ }
175
+
176
+ /** Serializes an entry for storage: plain number when multiplier-only. */
177
+ function serializeEntry(entry: CachedTrustEntry): number | CachedTrustEntry {
178
+ const m = clampMultiplier(entry.m);
179
+ if (!entry.mode && !entry.abs && !entry.verified) {
180
+ return m;
181
+ }
182
+
183
+ const serialized: CachedTrustEntry = { m };
184
+ if (entry.mode) {
185
+ serialized.mode = entry.mode;
186
+ }
187
+ if (entry.abs) {
188
+ const abs: { minute?: number; hour?: number; day?: number } = {};
189
+ for (const window of ['minute', 'hour', 'day'] as const) {
190
+ if (entry.abs[window] != null) {
191
+ abs[window] = entry.abs[window];
192
+ }
193
+ }
194
+ if (abs.minute != null || abs.hour != null || abs.day != null) {
195
+ serialized.abs = abs;
196
+ }
197
+ }
198
+ if (entry.verified) {
199
+ serialized.verified = true;
200
+ }
201
+ return serialized;
202
+ }
203
+
204
+ function isActiveEntry(entry: CachedTrustEntry): boolean {
205
+ return (
206
+ entry.mode != null ||
207
+ entry.verified === true ||
208
+ entry.m > NEUTRAL_TRUST_MULTIPLIER
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Resolves the cached trust multiplier for each provided subject key.
214
+ *
215
+ * Returns a map of subject key -> clamped multiplier for keys that have a cache
216
+ * entry above the neutral baseline, admin-mode entries, or neutral verified
217
+ * session entries. Missing keys are omitted. Returns an empty map on any Redis
218
+ * error (fail-open). Lets callers distinguish account trust (session key) from
219
+ * location trust (cidr/ip keys).
220
+ */
221
+ export async function getCachedTrustEntries(
222
+ subjectKeys: string[]
223
+ ): Promise<Map<string, CachedTrustEntry>> {
224
+ const result = new Map<string, CachedTrustEntry>();
225
+ if (subjectKeys.length === 0) {
226
+ return result;
227
+ }
228
+
229
+ try {
230
+ const redis = await getTrustRedisClient();
231
+ if (!redis) {
232
+ return result;
233
+ }
234
+
235
+ const cacheKeys = subjectKeys.map(buildTrustCacheKey);
236
+ const values = await redis.mget<unknown[]>(...cacheKeys);
237
+
238
+ subjectKeys.forEach((subjectKey, index) => {
239
+ const entry = parseEntry(values[index]);
240
+ if (entry && isActiveEntry(entry)) {
241
+ result.set(subjectKey, entry);
242
+ }
243
+ });
244
+
245
+ return result;
246
+ } catch {
247
+ // Fail-open: trust uplift is a best-effort optimization, never a gate.
248
+ return result;
249
+ }
250
+ }
251
+
252
+ export async function getCachedTrustMultipliers(
253
+ subjectKeys: string[]
254
+ ): Promise<Map<string, number>> {
255
+ const entries = await getCachedTrustEntries(subjectKeys);
256
+ const result = new Map<string, number>();
257
+ for (const [subjectKey, entry] of entries) {
258
+ result.set(subjectKey, entry.m);
259
+ }
260
+ return result;
261
+ }
262
+
263
+ /**
264
+ * Resolves the most-trusting cached multiplier across the provided subject keys.
265
+ *
266
+ * Returns the max multiplier present (most-trusting wins, consistent with the
267
+ * DB `get_rate_limit_trust_decision` ordering where trusted > standard). Returns
268
+ * a neutral 1.0 when nothing is cached or on any Redis error (fail-open).
269
+ */
270
+ export async function getCachedTrustMultiplier(
271
+ subjectKeys: string[]
272
+ ): Promise<number> {
273
+ const multipliers = await getCachedTrustMultipliers(subjectKeys);
274
+
275
+ let best = NEUTRAL_TRUST_MULTIPLIER;
276
+ for (const value of multipliers.values()) {
277
+ if (value > best) {
278
+ best = value;
279
+ }
280
+ }
281
+
282
+ return clampMultiplier(best);
283
+ }
284
+
285
+ /**
286
+ * Write-through a single subject's full rate-limit entry (multiplier + optional
287
+ * absolute/unlimited mode) to the edge cache. Stored as a plain number for the
288
+ * multiplier-only case (back-compat), JSON otherwise.
289
+ */
290
+ export async function setCachedTrustEntry(
291
+ subjectKey: string,
292
+ entry: CachedTrustEntry,
293
+ ttlSeconds: number = getTrustCacheTtlSeconds()
294
+ ): Promise<void> {
295
+ try {
296
+ const redis = await getTrustRedisClient();
297
+ if (!redis) {
298
+ return;
299
+ }
300
+
301
+ await redis.set(buildTrustCacheKey(subjectKey), serializeEntry(entry), {
302
+ ex: ttlSeconds,
303
+ });
304
+ } catch {
305
+ // Cache writes must never block the protected request path.
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Write-through a single subject's trust multiplier to the edge cache.
311
+ */
312
+ export async function setCachedTrustMultiplier(
313
+ subjectKey: string,
314
+ multiplier: number,
315
+ ttlSeconds: number = getTrustCacheTtlSeconds()
316
+ ): Promise<void> {
317
+ try {
318
+ const redis = await getTrustRedisClient();
319
+ if (!redis) {
320
+ return;
321
+ }
322
+
323
+ await redis.set(
324
+ buildTrustCacheKey(subjectKey),
325
+ clampMultiplier(multiplier),
326
+ {
327
+ ex: ttlSeconds,
328
+ }
329
+ );
330
+ } catch {
331
+ // Cache writes must never block the protected request path.
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Write-through the same trust multiplier to every subject key resolved for a
337
+ * request. Used by the reputation write-through path and the reconciliation
338
+ * cron. Skips writes entirely for the neutral multiplier to avoid pinning
339
+ * standard subjects in the cache.
340
+ */
341
+ export async function writeTrustCacheForSubjects(
342
+ subjectKeys: string[],
343
+ multiplier: number,
344
+ ttlSeconds: number = getTrustCacheTtlSeconds()
345
+ ): Promise<void> {
346
+ const clamped = clampMultiplier(multiplier);
347
+ if (subjectKeys.length === 0 || clamped === NEUTRAL_TRUST_MULTIPLIER) {
348
+ return;
349
+ }
350
+
351
+ try {
352
+ const redis = await getTrustRedisClient();
353
+ if (!redis) {
354
+ return;
355
+ }
356
+
357
+ await Promise.all(
358
+ subjectKeys.map((subjectKey) =>
359
+ redis.set(buildTrustCacheKey(subjectKey), clamped, { ex: ttlSeconds })
360
+ )
361
+ );
362
+ } catch {
363
+ // Cache writes must never block the protected request path.
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Write-through a neutral marker for server-verified browser/app sessions.
369
+ * This only proves the session cookie was successfully validated downstream; it
370
+ * does not grant a read-limit multiplier or admin override.
371
+ */
372
+ export async function writeVerifiedSessionCacheForSubjects(
373
+ subjectKeys: string[],
374
+ ttlSeconds: number = getTrustCacheTtlSeconds()
375
+ ): Promise<void> {
376
+ const sessionKeys = subjectKeys.filter((subjectKey) =>
377
+ subjectKey.startsWith('session:')
378
+ );
379
+ if (sessionKeys.length === 0) {
380
+ return;
381
+ }
382
+
383
+ try {
384
+ const redis = await getTrustRedisClient();
385
+ if (!redis) {
386
+ return;
387
+ }
388
+
389
+ const existingEntries = await getCachedTrustEntries(sessionKeys);
390
+ const verifiedEntry = serializeEntry({
391
+ m: NEUTRAL_TRUST_MULTIPLIER,
392
+ verified: true,
393
+ });
394
+ await Promise.all(
395
+ sessionKeys.map((subjectKey) => {
396
+ const existing = existingEntries.get(subjectKey);
397
+ if (
398
+ existing &&
399
+ (existing.mode != null || existing.abs != null || existing.m > 1)
400
+ ) {
401
+ return Promise.resolve();
402
+ }
403
+
404
+ return redis.set(buildTrustCacheKey(subjectKey), verifiedEntry, {
405
+ ex: ttlSeconds,
406
+ });
407
+ })
408
+ );
409
+ } catch {
410
+ // Cache writes must never block the protected request path.
411
+ }
412
+ }