@tuturuuu/utils 0.6.1 → 0.8.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.
@@ -0,0 +1,200 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ getClient: vi.fn(),
5
+ mget: vi.fn(),
6
+ set: vi.fn(),
7
+ }));
8
+
9
+ vi.mock('../../upstash-rest', () => ({
10
+ getUpstashRestRedisClient: () => mocks.getClient(),
11
+ }));
12
+
13
+ function mockRedisClient() {
14
+ mocks.getClient.mockResolvedValue({
15
+ mget: (...keys: string[]) => mocks.mget(...keys),
16
+ set: (key: string, value: unknown, options?: unknown) =>
17
+ mocks.set(key, value, options),
18
+ });
19
+ }
20
+
21
+ describe('edge-trust cache', () => {
22
+ beforeEach(() => {
23
+ vi.resetModules();
24
+ mocks.getClient.mockReset();
25
+ mocks.mget.mockReset();
26
+ mocks.set.mockReset();
27
+ });
28
+
29
+ afterEach(() => {
30
+ vi.unstubAllEnvs();
31
+ });
32
+
33
+ it('returns only elevated subjects from getCachedTrustMultipliers', async () => {
34
+ mockRedisClient();
35
+ // Order of values matches the order of keys requested.
36
+ mocks.mget.mockResolvedValue([3, null, 1, 2]);
37
+
38
+ const { getCachedTrustMultipliers } = await import('../edge-trust.js');
39
+ const result = await getCachedTrustMultipliers([
40
+ 'session:aaa',
41
+ 'cidr:1.2.3.0/24',
42
+ 'ip:1.2.3.4',
43
+ 'user:abc',
44
+ ]);
45
+
46
+ expect(result.get('session:aaa')).toBe(3);
47
+ expect(result.has('cidr:1.2.3.0/24')).toBe(false); // null
48
+ expect(result.has('ip:1.2.3.4')).toBe(false); // neutral (1)
49
+ expect(result.get('user:abc')).toBe(2);
50
+ });
51
+
52
+ it('clamps cached multipliers to the DB ceiling', async () => {
53
+ mockRedisClient();
54
+ mocks.mget.mockResolvedValue([5000]);
55
+
56
+ const { getCachedTrustMultipliers } = await import('../edge-trust.js');
57
+ const result = await getCachedTrustMultipliers(['session:aaa']);
58
+
59
+ expect(result.get('session:aaa')).toBe(1000);
60
+ });
61
+
62
+ it('takes the most-trusting multiplier in getCachedTrustMultiplier', async () => {
63
+ mockRedisClient();
64
+ mocks.mget.mockResolvedValue([2, 3]);
65
+
66
+ const { getCachedTrustMultiplier } = await import('../edge-trust.js');
67
+ const best = await getCachedTrustMultiplier([
68
+ 'cidr:1.2.3.0/24',
69
+ 'session:aaa',
70
+ ]);
71
+
72
+ expect(best).toBe(3);
73
+ });
74
+
75
+ it('parses string multipliers from Redis', async () => {
76
+ mockRedisClient();
77
+ mocks.mget.mockResolvedValue(['2.5']);
78
+
79
+ const { getCachedTrustMultiplier } = await import('../edge-trust.js');
80
+ expect(await getCachedTrustMultiplier(['session:aaa'])).toBe(2.5);
81
+ });
82
+
83
+ it('fails open to a neutral multiplier when Redis errors', async () => {
84
+ mockRedisClient();
85
+ mocks.mget.mockRejectedValue(new Error('redis down'));
86
+
87
+ const { getCachedTrustMultiplier, getCachedTrustMultipliers } =
88
+ await import('../edge-trust.js');
89
+
90
+ expect(await getCachedTrustMultiplier(['session:aaa'])).toBe(1);
91
+ expect((await getCachedTrustMultipliers(['session:aaa'])).size).toBe(0);
92
+ });
93
+
94
+ it('fails open when no Redis client is configured', async () => {
95
+ mocks.getClient.mockResolvedValue(null);
96
+
97
+ const { getCachedTrustMultiplier } = await import('../edge-trust.js');
98
+ expect(await getCachedTrustMultiplier(['session:aaa'])).toBe(1);
99
+ });
100
+
101
+ it('writes a clamped multiplier with a TTL', async () => {
102
+ mockRedisClient();
103
+ mocks.set.mockResolvedValue('OK');
104
+
105
+ const { setCachedTrustMultiplier } = await import('../edge-trust.js');
106
+ await setCachedTrustMultiplier('cidr:1.2.3.0/24', 5000, 7200);
107
+
108
+ expect(mocks.set).toHaveBeenCalledWith('trust:mult:cidr:1.2.3.0/24', 1000, {
109
+ ex: 7200,
110
+ });
111
+ });
112
+
113
+ it('skips neutral multipliers in writeTrustCacheForSubjects', async () => {
114
+ mockRedisClient();
115
+ mocks.set.mockResolvedValue('OK');
116
+
117
+ const { writeTrustCacheForSubjects } = await import('../edge-trust.js');
118
+ await writeTrustCacheForSubjects(['session:aaa', 'cidr:1.2.3.0/24'], 1);
119
+
120
+ expect(mocks.set).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('writes every subject key for an elevated multiplier', async () => {
124
+ mockRedisClient();
125
+ mocks.set.mockResolvedValue('OK');
126
+
127
+ const { writeTrustCacheForSubjects } = await import('../edge-trust.js');
128
+ await writeTrustCacheForSubjects(
129
+ ['session:aaa', 'cidr:1.2.3.0/24'],
130
+ 3,
131
+ 600
132
+ );
133
+
134
+ expect(mocks.set).toHaveBeenCalledWith('trust:mult:session:aaa', 3, {
135
+ ex: 600,
136
+ });
137
+ expect(mocks.set).toHaveBeenCalledWith('trust:mult:cidr:1.2.3.0/24', 3, {
138
+ ex: 600,
139
+ });
140
+ });
141
+
142
+ it('parses legacy plain numbers and rich JSON entries together', async () => {
143
+ mockRedisClient();
144
+ mocks.mget.mockResolvedValue([
145
+ 3, // legacy plain number
146
+ { m: 1, mode: 'unlimited' }, // rich object (auto-deserialized by upstash)
147
+ { m: 1, mode: 'absolute', abs: { minute: 500 } },
148
+ ]);
149
+
150
+ const { getCachedTrustEntries } = await import('../edge-trust.js');
151
+ const entries = await getCachedTrustEntries([
152
+ 'session:legacy',
153
+ 'cidr:unl',
154
+ 'ip:abs',
155
+ ]);
156
+
157
+ expect(entries.get('session:legacy')).toEqual({ m: 3 });
158
+ expect(entries.get('cidr:unl')).toEqual({ m: 1, mode: 'unlimited' });
159
+ expect(entries.get('ip:abs')).toEqual({
160
+ abs: { minute: 500 },
161
+ m: 1,
162
+ mode: 'absolute',
163
+ });
164
+ });
165
+
166
+ it('writes rich entries as JSON and multiplier-only as a plain number', async () => {
167
+ mockRedisClient();
168
+ mocks.set.mockResolvedValue('OK');
169
+
170
+ const { setCachedTrustEntry } = await import('../edge-trust.js');
171
+ await setCachedTrustEntry('session:a', { m: 3 }, 600);
172
+ await setCachedTrustEntry(
173
+ 'cidr:b',
174
+ { abs: { minute: 500 }, m: 1, mode: 'absolute' },
175
+ 600
176
+ );
177
+
178
+ expect(mocks.set).toHaveBeenCalledWith('trust:mult:session:a', 3, {
179
+ ex: 600,
180
+ });
181
+ expect(mocks.set).toHaveBeenCalledWith(
182
+ 'trust:mult:cidr:b',
183
+ { abs: { minute: 500 }, m: 1, mode: 'absolute' },
184
+ { ex: 600 }
185
+ );
186
+ });
187
+
188
+ it('honors the configurable cache TTL', async () => {
189
+ vi.stubEnv('EDGE_TRUST_CACHE_TTL_SECONDS', '900');
190
+ mockRedisClient();
191
+ mocks.set.mockResolvedValue('OK');
192
+
193
+ const { setCachedTrustMultiplier } = await import('../edge-trust.js');
194
+ await setCachedTrustMultiplier('session:aaa', 3);
195
+
196
+ expect(mocks.set).toHaveBeenCalledWith('trust:mult:session:aaa', 3, {
197
+ ex: 900,
198
+ });
199
+ });
200
+ });
@@ -7,12 +7,15 @@ import {
7
7
  ABUSE_THRESHOLDS,
8
8
  BLOCK_DURATIONS,
9
9
  checkOTPSendAllowed,
10
+ checkPasswordLoginLimit,
10
11
  classifyPotentialSpamUserAgent,
12
+ clearPasswordLoginFailures,
11
13
  extractIPFromHeaders,
12
14
  hashEmail,
13
15
  MAX_BLOCK_LEVEL,
14
16
  REDIS_KEYS,
15
17
  recordOTPSendSuccess,
18
+ recordPasswordLoginFailure,
16
19
  resetOtpLimitsForEmail,
17
20
  WINDOW_MS,
18
21
  } from '../index';
@@ -241,6 +244,54 @@ describe('abuse-protection', () => {
241
244
  5 * 60 * 1000
242
245
  );
243
246
  expect(ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX).toBe(10);
247
+ expect(ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_WINDOW_MS).toBe(
248
+ 15 * 60 * 1000
249
+ );
250
+ expect(ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_EMAIL_MAX).toBe(10);
251
+ });
252
+ });
253
+
254
+ describe('password login failure limits', () => {
255
+ it('keeps the IP-wide failure counter after another account succeeds', async () => {
256
+ const ipAddress = '203.0.113.41';
257
+
258
+ for (let attempt = 0; attempt < 9; attempt += 1) {
259
+ await recordPasswordLoginFailure(ipAddress, 'victim@example.com');
260
+ }
261
+
262
+ await clearPasswordLoginFailures(ipAddress, 'attacker@example.com');
263
+
264
+ await expect(
265
+ checkPasswordLoginLimit(ipAddress, 'victim@example.com')
266
+ ).resolves.toMatchObject({
267
+ allowed: true,
268
+ remainingAttempts: 1,
269
+ });
270
+ });
271
+
272
+ it('clears only the successful account email failure counter', async () => {
273
+ const email = 'targeted-user@example.com';
274
+
275
+ for (let attempt = 0; attempt < 10; attempt += 1) {
276
+ await recordPasswordLoginFailure(`198.51.100.${attempt + 1}`, email);
277
+ }
278
+
279
+ await expect(
280
+ checkPasswordLoginLimit('198.51.100.200', email)
281
+ ).resolves.toMatchObject({
282
+ allowed: false,
283
+ reason: 'Too many failed login attempts for this email',
284
+ remainingAttempts: 0,
285
+ });
286
+
287
+ await clearPasswordLoginFailures('198.51.100.200', email);
288
+
289
+ await expect(
290
+ checkPasswordLoginLimit('198.51.100.200', email)
291
+ ).resolves.toMatchObject({
292
+ allowed: true,
293
+ remainingAttempts: 10,
294
+ });
244
295
  });
245
296
  });
246
297
 
@@ -350,14 +401,19 @@ describe('abuse-protection', () => {
350
401
  });
351
402
 
352
403
  describe('checkOTPSendAllowed', () => {
353
- it('does not consume quota during preflight checks', async () => {
354
- const email = `preflight-${Date.now()}@example.com`;
355
-
356
- const firstAttempt = await checkOTPSendAllowed('198.51.100.1', email);
357
- const secondAttempt = await checkOTPSendAllowed('198.51.100.2', email);
358
-
359
- expect(firstAttempt.allowed).toBe(true);
360
- expect(secondAttempt.allowed).toBe(true);
404
+ it('reserves same-email cooldown before provider success', async () => {
405
+ const email = `reserved-cooldown-${Date.now()}@example.com`;
406
+
407
+ const attempts = await Promise.all([
408
+ checkOTPSendAllowed('198.51.100.1', email),
409
+ checkOTPSendAllowed('198.51.100.2', email),
410
+ ]);
411
+ const allowedAttempts = attempts.filter((attempt) => attempt.allowed);
412
+ const blockedAttempts = attempts.filter((attempt) => !attempt.allowed);
413
+
414
+ expect(allowedAttempts).toHaveLength(1);
415
+ expect(blockedAttempts).toHaveLength(1);
416
+ expect(blockedAttempts[0]?.retryAfter).toBeGreaterThan(0);
361
417
  });
362
418
 
363
419
  it('blocks repeated sends to the same email across different IPs during cooldown', async () => {
@@ -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,344 @@
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
+ }
43
+
44
+ const DEFAULT_TRUST_CACHE_TTL_SECONDS = 3600; // 1 hour
45
+
46
+ let trustRedisClient: UpstashRestRedisClient | null = null;
47
+ let trustRedisInitialized = false;
48
+
49
+ function parsePositiveIntEnv(name: string, fallback: number): number {
50
+ const rawValue = process.env[name];
51
+ if (!rawValue) {
52
+ return fallback;
53
+ }
54
+
55
+ const parsed = Number.parseInt(rawValue, 10);
56
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
57
+ }
58
+
59
+ export function getTrustCacheTtlSeconds(): number {
60
+ return parsePositiveIntEnv(
61
+ 'EDGE_TRUST_CACHE_TTL_SECONDS',
62
+ DEFAULT_TRUST_CACHE_TTL_SECONDS
63
+ );
64
+ }
65
+
66
+ async function getTrustRedisClient() {
67
+ if (trustRedisInitialized) return trustRedisClient;
68
+
69
+ try {
70
+ trustRedisClient = await getUpstashRestRedisClient();
71
+ trustRedisInitialized = true;
72
+ return trustRedisClient;
73
+ } catch {
74
+ trustRedisInitialized = true;
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Maps an abuse reputation subject key (e.g. `user:<id>`, `session:<hash>`,
81
+ * `cidr:1.2.3.0/24`) to its trust-cache Redis key.
82
+ */
83
+ export function buildTrustCacheKey(subjectKey: string): string {
84
+ return `${TRUST_CACHE_PREFIX}:${subjectKey}`;
85
+ }
86
+
87
+ function clampMultiplier(value: number): number {
88
+ if (!Number.isFinite(value) || value <= 0) {
89
+ return NEUTRAL_TRUST_MULTIPLIER;
90
+ }
91
+
92
+ return Math.min(MAX_TRUST_MULTIPLIER, Math.max(MIN_TRUST_MULTIPLIER, value));
93
+ }
94
+
95
+ function parseMultiplier(value: unknown): number | null {
96
+ if (typeof value === 'number') {
97
+ return Number.isFinite(value) ? value : null;
98
+ }
99
+
100
+ if (typeof value === 'string') {
101
+ const parsed = Number.parseFloat(value);
102
+ return Number.isFinite(parsed) ? parsed : null;
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ function parseAbsLimits(value: unknown): CachedTrustEntry['abs'] | undefined {
109
+ if (!value || typeof value !== 'object') {
110
+ return undefined;
111
+ }
112
+
113
+ const source = value as Record<string, unknown>;
114
+ const abs: { minute?: number; hour?: number; day?: number } = {};
115
+ for (const window of ['minute', 'hour', 'day'] as const) {
116
+ const parsed = parseMultiplier(source[window]);
117
+ if (parsed != null && parsed > 0) {
118
+ abs[window] = Math.floor(parsed);
119
+ }
120
+ }
121
+
122
+ return abs.minute != null || abs.hour != null || abs.day != null
123
+ ? abs
124
+ : undefined;
125
+ }
126
+
127
+ /**
128
+ * Parses a cached value into a {@link CachedTrustEntry}, accepting BOTH legacy
129
+ * plain numbers/numeric strings (back-compat) and the richer JSON object form.
130
+ */
131
+ function parseEntry(value: unknown): CachedTrustEntry | null {
132
+ const asNumber = parseMultiplier(value);
133
+ if (asNumber != null) {
134
+ return { m: clampMultiplier(asNumber) };
135
+ }
136
+
137
+ let source: unknown = value;
138
+ if (typeof value === 'string') {
139
+ try {
140
+ source = JSON.parse(value);
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ if (!source || typeof source !== 'object') {
147
+ return null;
148
+ }
149
+
150
+ const record = source as Record<string, unknown>;
151
+ const m = clampMultiplier(
152
+ parseMultiplier(record.m) ?? NEUTRAL_TRUST_MULTIPLIER
153
+ );
154
+ const mode =
155
+ record.mode === 'absolute' || record.mode === 'unlimited'
156
+ ? record.mode
157
+ : undefined;
158
+ const abs = parseAbsLimits(record.abs);
159
+
160
+ return { abs, m, mode };
161
+ }
162
+
163
+ /** Serializes an entry for storage: plain number when multiplier-only. */
164
+ function serializeEntry(entry: CachedTrustEntry): number | CachedTrustEntry {
165
+ const m = clampMultiplier(entry.m);
166
+ if (!entry.mode && !entry.abs) {
167
+ return m;
168
+ }
169
+
170
+ const serialized: CachedTrustEntry = { m };
171
+ if (entry.mode) {
172
+ serialized.mode = entry.mode;
173
+ }
174
+ if (entry.abs) {
175
+ const abs: { minute?: number; hour?: number; day?: number } = {};
176
+ for (const window of ['minute', 'hour', 'day'] as const) {
177
+ if (entry.abs[window] != null) {
178
+ abs[window] = entry.abs[window];
179
+ }
180
+ }
181
+ if (abs.minute != null || abs.hour != null || abs.day != null) {
182
+ serialized.abs = abs;
183
+ }
184
+ }
185
+ return serialized;
186
+ }
187
+
188
+ function isActiveEntry(entry: CachedTrustEntry): boolean {
189
+ return entry.mode != null || entry.m > NEUTRAL_TRUST_MULTIPLIER;
190
+ }
191
+
192
+ /**
193
+ * Resolves the cached trust multiplier for each provided subject key.
194
+ *
195
+ * Returns a map of subject key -> clamped multiplier for keys that have a cache
196
+ * entry above the neutral baseline. Missing keys are omitted. Returns an empty
197
+ * map on any Redis error (fail-open). Lets callers distinguish account trust
198
+ * (session key) from location trust (cidr/ip keys).
199
+ */
200
+ export async function getCachedTrustEntries(
201
+ subjectKeys: string[]
202
+ ): Promise<Map<string, CachedTrustEntry>> {
203
+ const result = new Map<string, CachedTrustEntry>();
204
+ if (subjectKeys.length === 0) {
205
+ return result;
206
+ }
207
+
208
+ try {
209
+ const redis = await getTrustRedisClient();
210
+ if (!redis) {
211
+ return result;
212
+ }
213
+
214
+ const cacheKeys = subjectKeys.map(buildTrustCacheKey);
215
+ const values = await redis.mget<unknown[]>(...cacheKeys);
216
+
217
+ subjectKeys.forEach((subjectKey, index) => {
218
+ const entry = parseEntry(values[index]);
219
+ if (entry && isActiveEntry(entry)) {
220
+ result.set(subjectKey, entry);
221
+ }
222
+ });
223
+
224
+ return result;
225
+ } catch {
226
+ // Fail-open: trust uplift is a best-effort optimization, never a gate.
227
+ return result;
228
+ }
229
+ }
230
+
231
+ export async function getCachedTrustMultipliers(
232
+ subjectKeys: string[]
233
+ ): Promise<Map<string, number>> {
234
+ const entries = await getCachedTrustEntries(subjectKeys);
235
+ const result = new Map<string, number>();
236
+ for (const [subjectKey, entry] of entries) {
237
+ result.set(subjectKey, entry.m);
238
+ }
239
+ return result;
240
+ }
241
+
242
+ /**
243
+ * Resolves the most-trusting cached multiplier across the provided subject keys.
244
+ *
245
+ * Returns the max multiplier present (most-trusting wins, consistent with the
246
+ * DB `get_rate_limit_trust_decision` ordering where trusted > standard). Returns
247
+ * a neutral 1.0 when nothing is cached or on any Redis error (fail-open).
248
+ */
249
+ export async function getCachedTrustMultiplier(
250
+ subjectKeys: string[]
251
+ ): Promise<number> {
252
+ const multipliers = await getCachedTrustMultipliers(subjectKeys);
253
+
254
+ let best = NEUTRAL_TRUST_MULTIPLIER;
255
+ for (const value of multipliers.values()) {
256
+ if (value > best) {
257
+ best = value;
258
+ }
259
+ }
260
+
261
+ return clampMultiplier(best);
262
+ }
263
+
264
+ /**
265
+ * Write-through a single subject's full rate-limit entry (multiplier + optional
266
+ * absolute/unlimited mode) to the edge cache. Stored as a plain number for the
267
+ * multiplier-only case (back-compat), JSON otherwise.
268
+ */
269
+ export async function setCachedTrustEntry(
270
+ subjectKey: string,
271
+ entry: CachedTrustEntry,
272
+ ttlSeconds: number = getTrustCacheTtlSeconds()
273
+ ): Promise<void> {
274
+ try {
275
+ const redis = await getTrustRedisClient();
276
+ if (!redis) {
277
+ return;
278
+ }
279
+
280
+ await redis.set(buildTrustCacheKey(subjectKey), serializeEntry(entry), {
281
+ ex: ttlSeconds,
282
+ });
283
+ } catch {
284
+ // Cache writes must never block the protected request path.
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Write-through a single subject's trust multiplier to the edge cache.
290
+ */
291
+ export async function setCachedTrustMultiplier(
292
+ subjectKey: string,
293
+ multiplier: number,
294
+ ttlSeconds: number = getTrustCacheTtlSeconds()
295
+ ): Promise<void> {
296
+ try {
297
+ const redis = await getTrustRedisClient();
298
+ if (!redis) {
299
+ return;
300
+ }
301
+
302
+ await redis.set(
303
+ buildTrustCacheKey(subjectKey),
304
+ clampMultiplier(multiplier),
305
+ {
306
+ ex: ttlSeconds,
307
+ }
308
+ );
309
+ } catch {
310
+ // Cache writes must never block the protected request path.
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Write-through the same trust multiplier to every subject key resolved for a
316
+ * request. Used by the reputation write-through path and the reconciliation
317
+ * cron. Skips writes entirely for the neutral multiplier to avoid pinning
318
+ * standard subjects in the cache.
319
+ */
320
+ export async function writeTrustCacheForSubjects(
321
+ subjectKeys: string[],
322
+ multiplier: number,
323
+ ttlSeconds: number = getTrustCacheTtlSeconds()
324
+ ): Promise<void> {
325
+ const clamped = clampMultiplier(multiplier);
326
+ if (subjectKeys.length === 0 || clamped === NEUTRAL_TRUST_MULTIPLIER) {
327
+ return;
328
+ }
329
+
330
+ try {
331
+ const redis = await getTrustRedisClient();
332
+ if (!redis) {
333
+ return;
334
+ }
335
+
336
+ await Promise.all(
337
+ subjectKeys.map((subjectKey) =>
338
+ redis.set(buildTrustCacheKey(subjectKey), clamped, { ex: ttlSeconds })
339
+ )
340
+ );
341
+ } catch {
342
+ // Cache writes must never block the protected request path.
343
+ }
344
+ }