@tuturuuu/utils 0.8.0 → 0.10.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.
@@ -264,6 +264,7 @@ describe('Portless app origin registry', () => {
264
264
  'https://finance.tuturuuu.localhost',
265
265
  'https://hive.tuturuuu.localhost',
266
266
  'https://realtime.hive.tuturuuu.localhost',
267
+ 'https://infra.tuturuuu.localhost',
267
268
  'https://inventory.tuturuuu.localhost',
268
269
  'https://learn.tuturuuu.localhost',
269
270
  'https://mail.tuturuuu.localhost',
@@ -276,6 +277,7 @@ describe('Portless app origin registry', () => {
276
277
  'https://rewise.tuturuuu.localhost',
277
278
  'https://shortener.tuturuuu.localhost',
278
279
  'https://storefront.tuturuuu.localhost',
280
+ 'https://tanstack.tuturuuu.localhost',
279
281
  'https://tasks.tuturuuu.localhost',
280
282
  'https://teach.tuturuuu.localhost',
281
283
  'https://track.tuturuuu.localhost',
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ createTaskDescriptionPayload,
4
+ decodeTaskDescriptionYjsState,
5
+ encodeTaskDescriptionYjsState,
6
+ parseMarkdownTaskDescriptionContent,
7
+ parsePersistedTaskDescriptionContent,
8
+ parseTaskDescriptionInput,
9
+ parseTaskDescriptionJson,
10
+ taskDescriptionPlainText,
11
+ taskDescriptionYjsStateFromBase64,
12
+ taskDescriptionYjsStateFromBytesJson,
13
+ taskDescriptionYjsStateToBase64,
14
+ } from '../task-description-codec';
15
+
16
+ describe('task description codec', () => {
17
+ it('creates storage payloads from plain text', () => {
18
+ const payload = createTaskDescriptionPayload(
19
+ 'First paragraph\n\nSecond paragraph'
20
+ );
21
+
22
+ expect(payload.description).toContain('First paragraph');
23
+ expect(payload.description_yjs_state?.length ?? 0).toBeGreaterThan(0);
24
+ expect(taskDescriptionPlainText(payload.content)).toContain(
25
+ 'Second paragraph'
26
+ );
27
+ });
28
+
29
+ it('parses common markdown blocks into TipTap content', () => {
30
+ const content = parseMarkdownTaskDescriptionContent(
31
+ [
32
+ '# Release notes',
33
+ '',
34
+ '- [x] Confirm CLI auth',
35
+ '- [ ] Ship task descriptions',
36
+ '',
37
+ '> Keep this scoped',
38
+ '',
39
+ 'Visit [Tuturuuu](https://tuturuuu.com) with **bold** text.',
40
+ '',
41
+ '```ts',
42
+ 'const shipped = true;',
43
+ '```',
44
+ ].join('\n')
45
+ );
46
+
47
+ const plainText = taskDescriptionPlainText(content);
48
+
49
+ expect(plainText).toContain('# Release notes');
50
+ expect(plainText).toContain('[x] Confirm CLI auth');
51
+ expect(plainText).toContain('> Keep this scoped');
52
+ expect(plainText).toContain('const shipped = true;');
53
+ });
54
+
55
+ it('round-trips TipTap content through Yjs base64', () => {
56
+ const content = parseTaskDescriptionInput(
57
+ JSON.stringify({
58
+ content: [
59
+ {
60
+ attrs: { level: 2 },
61
+ content: [{ text: 'Task heading', type: 'text' }],
62
+ type: 'heading',
63
+ },
64
+ ],
65
+ type: 'doc',
66
+ }),
67
+ 'json'
68
+ );
69
+ const yjsState = encodeTaskDescriptionYjsState(content);
70
+ const encoded = taskDescriptionYjsStateToBase64(yjsState);
71
+ const decoded = decodeTaskDescriptionYjsState(
72
+ taskDescriptionYjsStateFromBase64(encoded ?? '')
73
+ );
74
+
75
+ expect(taskDescriptionPlainText(decoded)).toContain('Task heading');
76
+ });
77
+
78
+ it('rejects explicit non-doc JSON input', () => {
79
+ expect(() => parseTaskDescriptionJson('{"text":"not a doc"}')).toThrow(
80
+ /TipTap doc/
81
+ );
82
+ });
83
+
84
+ it('validates bytes JSON input', () => {
85
+ expect(taskDescriptionYjsStateFromBytesJson('[1,2,255]')).toEqual([
86
+ 1, 2, 255,
87
+ ]);
88
+ expect(() => taskDescriptionYjsStateFromBytesJson('[1,999]')).toThrow(
89
+ /array of byte/
90
+ );
91
+ });
92
+
93
+ it('prefers persisted Yjs state over stale description JSON', () => {
94
+ const fresh = createTaskDescriptionPayload('Fresh state');
95
+ const content = parsePersistedTaskDescriptionContent({
96
+ description: JSON.stringify({
97
+ content: [
98
+ {
99
+ content: [{ text: 'Stale projection', type: 'text' }],
100
+ type: 'paragraph',
101
+ },
102
+ ],
103
+ type: 'doc',
104
+ }),
105
+ description_yjs_state: fresh.description_yjs_state,
106
+ });
107
+
108
+ expect(taskDescriptionPlainText(content)).toContain('Fresh state');
109
+ });
110
+ });
@@ -2,8 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  const mocks = vi.hoisted(() => ({
4
4
  blockIPEdge: vi.fn(),
5
- checkUserSuspension: vi.fn(),
6
- suspendUser: vi.fn(),
7
5
  }));
8
6
 
9
7
  vi.mock('../edge', () => ({
@@ -11,14 +9,6 @@ vi.mock('../edge', () => ({
11
9
  mocks.blockIPEdge(...args),
12
10
  }));
13
11
 
14
- vi.mock('../user-suspension', () => ({
15
- checkUserSuspension: (
16
- ...args: Parameters<typeof mocks.checkUserSuspension>
17
- ) => mocks.checkUserSuspension(...args),
18
- suspendUser: (...args: Parameters<typeof mocks.suspendUser>) =>
19
- mocks.suspendUser(...args),
20
- }));
21
-
22
12
  import {
23
13
  cascadeBackendRateLimitToProxyBan,
24
14
  isBackendRateLimitError,
@@ -28,8 +18,6 @@ describe('backend-rate-limit', () => {
28
18
  beforeEach(() => {
29
19
  vi.clearAllMocks();
30
20
  mocks.blockIPEdge.mockResolvedValue(null);
31
- mocks.checkUserSuspension.mockResolvedValue({ suspended: false });
32
- mocks.suspendUser.mockResolvedValue(true);
33
21
  });
34
22
 
35
23
  describe('isBackendRateLimitError', () => {
@@ -51,16 +39,7 @@ describe('backend-rate-limit', () => {
51
39
  });
52
40
 
53
41
  describe('cascadeBackendRateLimitToProxyBan', () => {
54
- it('blocks the IP without suspending an identified user', async () => {
55
- const blockInfo = {
56
- id: 'block-1',
57
- blockLevel: 1,
58
- reason: 'api_abuse',
59
- blockedAt: new Date(),
60
- expiresAt: new Date(Date.now() + 300_000),
61
- };
62
- mocks.blockIPEdge.mockResolvedValue(blockInfo);
63
-
42
+ it('does not hard-ban a known client IP for a backend rate-limit signal', async () => {
64
43
  const result = await cascadeBackendRateLimitToProxyBan({
65
44
  endpoint: '/api/ai/chat/new',
66
45
  ipAddress: '203.0.113.10',
@@ -68,46 +47,32 @@ describe('backend-rate-limit', () => {
68
47
  userId: 'user-1',
69
48
  });
70
49
 
71
- expect(result).toBe(blockInfo);
72
- expect(mocks.blockIPEdge).toHaveBeenCalledWith(
73
- '203.0.113.10',
74
- 'api_abuse'
75
- );
76
- expect(mocks.checkUserSuspension).not.toHaveBeenCalled();
77
- expect(mocks.suspendUser).not.toHaveBeenCalled();
50
+ expect(result).toBeNull();
51
+ expect(mocks.blockIPEdge).not.toHaveBeenCalled();
78
52
  });
79
53
 
80
- it('does not inspect existing user suspensions', async () => {
81
- mocks.checkUserSuspension.mockResolvedValue({
82
- suspended: true,
83
- reason: 'already suspended',
84
- });
85
-
86
- await cascadeBackendRateLimitToProxyBan({
54
+ it('does not hard-ban auth backend rate-limit signals', async () => {
55
+ const result = await cascadeBackendRateLimitToProxyBan({
87
56
  endpoint: '/api/ai/chat/new',
88
57
  ipAddress: '203.0.113.10',
89
58
  source: 'auth',
90
59
  userId: 'user-1',
91
60
  });
92
61
 
93
- expect(mocks.suspendUser).not.toHaveBeenCalled();
94
- expect(mocks.blockIPEdge).toHaveBeenCalledWith(
95
- '203.0.113.10',
96
- 'api_abuse'
97
- );
98
- expect(mocks.checkUserSuspension).not.toHaveBeenCalled();
62
+ expect(result).toBeNull();
63
+ expect(mocks.blockIPEdge).not.toHaveBeenCalled();
99
64
  });
100
65
 
101
- it('does not suspend the user when the client IP is unavailable', async () => {
102
- await cascadeBackendRateLimitToProxyBan({
66
+ it('returns null when the client IP is unavailable', async () => {
67
+ const result = await cascadeBackendRateLimitToProxyBan({
103
68
  endpoint: '/api/ai/chat/new',
104
69
  ipAddress: 'unknown',
105
70
  source: 'database',
106
71
  userId: 'user-1',
107
72
  });
108
73
 
74
+ expect(result).toBeNull();
109
75
  expect(mocks.blockIPEdge).not.toHaveBeenCalled();
110
- expect(mocks.suspendUser).not.toHaveBeenCalled();
111
76
  });
112
77
  });
113
78
  });
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  const mocks = vi.hoisted(() => ({
4
4
  getClient: vi.fn(),
5
+ get: vi.fn(),
5
6
  mget: vi.fn(),
6
7
  set: vi.fn(),
7
8
  }));
@@ -13,6 +14,7 @@ vi.mock('../../upstash-rest', () => ({
13
14
  function mockRedisClient() {
14
15
  mocks.getClient.mockResolvedValue({
15
16
  mget: (...keys: string[]) => mocks.mget(...keys),
17
+ get: (key: string) => mocks.get(key),
16
18
  set: (key: string, value: unknown, options?: unknown) =>
17
19
  mocks.set(key, value, options),
18
20
  });
@@ -22,6 +24,7 @@ describe('edge-trust cache', () => {
22
24
  beforeEach(() => {
23
25
  vi.resetModules();
24
26
  mocks.getClient.mockReset();
27
+ mocks.get.mockReset();
25
28
  mocks.mget.mockReset();
26
29
  mocks.set.mockReset();
27
30
  });
@@ -80,6 +83,17 @@ describe('edge-trust cache', () => {
80
83
  expect(await getCachedTrustMultiplier(['session:aaa'])).toBe(2.5);
81
84
  });
82
85
 
86
+ it('returns neutral verified session entries without treating plain neutral entries as active', async () => {
87
+ mockRedisClient();
88
+ mocks.mget.mockResolvedValue([{ m: 1, verified: true }, 1]);
89
+
90
+ const { getCachedTrustEntries } = await import('../edge-trust.js');
91
+ const entries = await getCachedTrustEntries(['session:aaa', 'session:bbb']);
92
+
93
+ expect(entries.get('session:aaa')).toEqual({ m: 1, verified: true });
94
+ expect(entries.has('session:bbb')).toBe(false);
95
+ });
96
+
83
97
  it('fails open to a neutral multiplier when Redis errors', async () => {
84
98
  mockRedisClient();
85
99
  mocks.mget.mockRejectedValue(new Error('redis down'));
@@ -139,6 +153,53 @@ describe('edge-trust cache', () => {
139
153
  });
140
154
  });
141
155
 
156
+ it('writes neutral verified session markers without writing non-session subjects', async () => {
157
+ mockRedisClient();
158
+ mocks.mget.mockResolvedValue([null]);
159
+ mocks.set.mockResolvedValue('OK');
160
+
161
+ const { writeVerifiedSessionCacheForSubjects } = await import(
162
+ '../edge-trust.js'
163
+ );
164
+ await writeVerifiedSessionCacheForSubjects(
165
+ ['session:aaa', 'cidr:1.2.3.0/24'],
166
+ 600
167
+ );
168
+
169
+ expect(mocks.set).toHaveBeenCalledTimes(1);
170
+ expect(mocks.set).toHaveBeenCalledWith(
171
+ 'trust:mult:session:aaa',
172
+ { m: 1, verified: true },
173
+ { ex: 600 }
174
+ );
175
+ });
176
+
177
+ it('does not downgrade elevated trust entries when writing verified session markers', async () => {
178
+ mockRedisClient();
179
+ mocks.mget.mockResolvedValue([3, null]);
180
+ mocks.set.mockResolvedValue('OK');
181
+
182
+ const { writeVerifiedSessionCacheForSubjects } = await import(
183
+ '../edge-trust.js'
184
+ );
185
+ await writeVerifiedSessionCacheForSubjects(
186
+ ['session:elevated', 'session:neutral'],
187
+ 600
188
+ );
189
+
190
+ expect(mocks.set).toHaveBeenCalledTimes(1);
191
+ expect(mocks.set).toHaveBeenCalledWith(
192
+ 'trust:mult:session:neutral',
193
+ { m: 1, verified: true },
194
+ { ex: 600 }
195
+ );
196
+ expect(mocks.set).not.toHaveBeenCalledWith(
197
+ 'trust:mult:session:elevated',
198
+ expect.anything(),
199
+ expect.anything()
200
+ );
201
+ });
202
+
142
203
  it('parses legacy plain numbers and rich JSON entries together', async () => {
143
204
  mockRedisClient();
144
205
  mocks.mget.mockResolvedValue([
@@ -197,4 +258,42 @@ describe('edge-trust cache', () => {
197
258
  ex: 900,
198
259
  });
199
260
  });
261
+
262
+ it('writes and reads a session plus IP scoped appeal relief key', async () => {
263
+ mockRedisClient();
264
+ mocks.set.mockResolvedValue('OK');
265
+ mocks.get.mockResolvedValue('1');
266
+
267
+ const {
268
+ buildIpBlockAppealReliefKey,
269
+ hasCachedIpBlockAppealRelief,
270
+ setCachedIpBlockAppealRelief,
271
+ } = await import('../edge-trust.js');
272
+
273
+ expect(buildIpBlockAppealReliefKey('session:abc', 'ip:203.0.113.10')).toBe(
274
+ 'trust:appeal-relief:session:abc:ip:203.0.113.10'
275
+ );
276
+
277
+ await setCachedIpBlockAppealRelief('session:abc', 'ip:203.0.113.10', 600);
278
+ expect(mocks.set).toHaveBeenCalledWith(
279
+ 'trust:appeal-relief:session:abc:ip:203.0.113.10',
280
+ '1',
281
+ { ex: 600 }
282
+ );
283
+
284
+ await expect(
285
+ hasCachedIpBlockAppealRelief('session:abc', 'ip:203.0.113.10')
286
+ ).resolves.toBe(true);
287
+ });
288
+
289
+ it('fails closed for appeal relief when Redis is unavailable', async () => {
290
+ mockRedisClient();
291
+ mocks.get.mockRejectedValue(new Error('redis down'));
292
+
293
+ const { hasCachedIpBlockAppealRelief } = await import('../edge-trust.js');
294
+
295
+ await expect(
296
+ hasCachedIpBlockAppealRelief('session:abc', 'ip:203.0.113.10')
297
+ ).resolves.toBe(false);
298
+ });
200
299
  });
@@ -3,19 +3,27 @@
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, it, vi } from 'vitest';
6
+
6
7
  import {
7
8
  ABUSE_THRESHOLDS,
8
9
  BLOCK_DURATIONS,
10
+ checkMFAVerifyLimit,
9
11
  checkOTPSendAllowed,
12
+ checkOTPVerifyLimit,
10
13
  checkPasswordLoginLimit,
14
+ checkReauthVerifyLimit,
11
15
  classifyPotentialSpamUserAgent,
12
16
  clearPasswordLoginFailures,
13
17
  extractIPFromHeaders,
14
18
  hashEmail,
19
+ isSharedIpThrottleOnlyBlockReason,
15
20
  MAX_BLOCK_LEVEL,
16
21
  REDIS_KEYS,
22
+ recordMFAVerifyFailure,
17
23
  recordOTPSendSuccess,
24
+ recordOTPVerifyFailure,
18
25
  recordPasswordLoginFailure,
26
+ recordReauthVerifyFailure,
19
27
  resetOtpLimitsForEmail,
20
28
  WINDOW_MS,
21
29
  } from '../index';
@@ -251,6 +259,23 @@ describe('abuse-protection', () => {
251
259
  });
252
260
  });
253
261
 
262
+ describe('shared-IP hard block suppression', () => {
263
+ it('keeps human-auth failure reasons throttle-only', () => {
264
+ expect(isSharedIpThrottleOnlyBlockReason('otp_send')).toBe(true);
265
+ expect(isSharedIpThrottleOnlyBlockReason('otp_verify_failed')).toBe(true);
266
+ expect(isSharedIpThrottleOnlyBlockReason('mfa_verify_failed')).toBe(true);
267
+ expect(isSharedIpThrottleOnlyBlockReason('reauth_verify_failed')).toBe(
268
+ true
269
+ );
270
+ expect(isSharedIpThrottleOnlyBlockReason('password_login_failed')).toBe(
271
+ true
272
+ );
273
+ expect(isSharedIpThrottleOnlyBlockReason('api_auth_failed')).toBe(false);
274
+ expect(isSharedIpThrottleOnlyBlockReason('api_abuse')).toBe(false);
275
+ expect(isSharedIpThrottleOnlyBlockReason('manual')).toBe(false);
276
+ });
277
+ });
278
+
254
279
  describe('password login failure limits', () => {
255
280
  it('keeps the IP-wide failure counter after another account succeeds', async () => {
256
281
  const ipAddress = '203.0.113.41';
@@ -293,6 +318,33 @@ describe('abuse-protection', () => {
293
318
  remainingAttempts: 10,
294
319
  });
295
320
  });
321
+
322
+ it('throttles shared-IP password failures without writing a hard IP block', async () => {
323
+ const ipAddress = '203.0.113.42';
324
+
325
+ for (
326
+ let attempt = 0;
327
+ attempt < ABUSE_THRESHOLDS.PASSWORD_LOGIN_FAILED_MAX;
328
+ attempt += 1
329
+ ) {
330
+ await recordPasswordLoginFailure(
331
+ ipAddress,
332
+ `password-shared-ip-${attempt}@example.com`
333
+ );
334
+ }
335
+
336
+ const result = await checkPasswordLoginLimit(
337
+ ipAddress,
338
+ 'another-user@example.com'
339
+ );
340
+
341
+ expect(result).toMatchObject({
342
+ allowed: false,
343
+ reason: 'Too many failed login attempts',
344
+ remainingAttempts: 0,
345
+ });
346
+ expect(result.blocked).not.toBe(true);
347
+ });
296
348
  });
297
349
 
298
350
  describe('BLOCK_DURATIONS constants', () => {
@@ -469,6 +521,90 @@ describe('abuse-protection', () => {
469
521
  expect(blockedAttempt.retryAfter).toBeGreaterThan(0);
470
522
  });
471
523
 
524
+ it('throttles aggressive shared-IP OTP sends without writing a hard IP block', async () => {
525
+ vi.stubEnv('ABUSE_OTP_SEND_IP_LIMIT_MINUTE', '1');
526
+ const ipAddress = '198.51.100.12';
527
+
528
+ await expect(
529
+ checkOTPSendAllowed(ipAddress, 'otp-shared-ip-1@example.com')
530
+ ).resolves.toMatchObject({ allowed: true });
531
+
532
+ const blockedAttempt = await checkOTPSendAllowed(
533
+ ipAddress,
534
+ 'otp-shared-ip-2@example.com'
535
+ );
536
+
537
+ expect(blockedAttempt).toMatchObject({
538
+ allowed: false,
539
+ remainingAttempts: 0,
540
+ });
541
+ expect(blockedAttempt.blocked).not.toBe(true);
542
+ });
543
+
544
+ it('throttles shared-IP OTP verify failures without writing a hard IP block', async () => {
545
+ const ipAddress = '198.51.100.13';
546
+
547
+ for (
548
+ let attempt = 0;
549
+ attempt < ABUSE_THRESHOLDS.OTP_VERIFY_FAILED_MAX;
550
+ attempt += 1
551
+ ) {
552
+ await recordOTPVerifyFailure(
553
+ ipAddress,
554
+ `otp-verify-shared-ip-${attempt}@example.com`
555
+ );
556
+ }
557
+
558
+ const result = await checkOTPVerifyLimit(
559
+ ipAddress,
560
+ 'legit-teacher@example.com'
561
+ );
562
+
563
+ expect(result).toMatchObject({
564
+ allowed: false,
565
+ reason: 'Too many failed verification attempts from this IP',
566
+ remainingAttempts: 0,
567
+ });
568
+ expect(result.blocked).not.toBe(true);
569
+ });
570
+
571
+ it('throttles shared-IP MFA and reauth failures without writing a hard IP block', async () => {
572
+ const mfaIpAddress = '198.51.100.14';
573
+ const reauthIpAddress = '198.51.100.15';
574
+
575
+ for (
576
+ let attempt = 0;
577
+ attempt < ABUSE_THRESHOLDS.MFA_VERIFY_FAILED_MAX;
578
+ attempt += 1
579
+ ) {
580
+ await recordMFAVerifyFailure(mfaIpAddress);
581
+ }
582
+
583
+ for (
584
+ let attempt = 0;
585
+ attempt < ABUSE_THRESHOLDS.REAUTH_VERIFY_FAILED_MAX;
586
+ attempt += 1
587
+ ) {
588
+ await recordReauthVerifyFailure(reauthIpAddress);
589
+ }
590
+
591
+ const mfaResult = await checkMFAVerifyLimit(mfaIpAddress);
592
+ const reauthResult = await checkReauthVerifyLimit(reauthIpAddress);
593
+
594
+ expect(mfaResult).toMatchObject({
595
+ allowed: false,
596
+ reason: 'Too many failed MFA verification attempts',
597
+ remainingAttempts: 0,
598
+ });
599
+ expect(reauthResult).toMatchObject({
600
+ allowed: false,
601
+ reason: 'Too many failed reauthentication attempts',
602
+ remainingAttempts: 0,
603
+ });
604
+ expect(mfaResult.blocked).not.toBe(true);
605
+ expect(reauthResult.blocked).not.toBe(true);
606
+ });
607
+
472
608
  it('caps successful sends for the same email across distributed IPs within an hour', async () => {
473
609
  vi.useFakeTimers();
474
610
  vi.setSystemTime(new Date('2026-03-12T00:00:00.000Z'));
@@ -1,4 +1,3 @@
1
- import { blockIPEdge } from './edge';
2
1
  import type { BlockInfo } from './types';
3
2
 
4
3
  type BackendRateLimitSource = 'auth' | 'database';
@@ -37,8 +36,10 @@ export function isBackendRateLimitError(
37
36
  }
38
37
 
39
38
  export async function cascadeBackendRateLimitToProxyBan({
40
- ipAddress,
39
+ ipAddress: _ipAddress,
41
40
  }: BackendRateLimitEscalationOptions): Promise<BlockInfo | null> {
42
- const shouldBlockIp = ipAddress && ipAddress !== 'unknown';
43
- return shouldBlockIp ? blockIPEdge(ipAddress, 'api_abuse') : null;
41
+ // A backend 429 is an availability/backpressure signal. It is not enough
42
+ // evidence to hard-ban a public NAT IP that may represent a classroom,
43
+ // university, or enterprise office.
44
+ return null;
44
45
  }