@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.
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
9
9
  extractIp: vi.fn(),
10
10
  isBlocked: vi.fn(),
11
11
  validateEmoji: vi.fn(),
12
+ getTrustEntries: vi.fn(),
12
13
  }));
13
14
 
14
15
  vi.mock('@upstash/ratelimit', () => {
@@ -50,6 +51,10 @@ vi.mock('../abuse-protection/edge', () => ({
50
51
  isIPBlockedEdge: (ip: string) => mocks.isBlocked(ip),
51
52
  }));
52
53
 
54
+ vi.mock('../abuse-protection/edge-trust', () => ({
55
+ getCachedTrustEntries: (keys: string[]) => mocks.getTrustEntries(keys),
56
+ }));
57
+
53
58
  vi.mock('../request-emoji-limit', () => ({
54
59
  validateRequestEmojiLimit: (
55
60
  req: NextRequest,
@@ -98,6 +103,9 @@ describe('guardApiProxyRequest', () => {
98
103
  mocks.extractIp.mockReset();
99
104
  mocks.isBlocked.mockReset();
100
105
  mocks.validateEmoji.mockReset();
106
+ mocks.getTrustEntries.mockReset();
107
+ // Default: no cached trust (untrusted). Tests opt in by overriding.
108
+ mocks.getTrustEntries.mockResolvedValue(new Map<string, { m: number }>());
101
109
  });
102
110
 
103
111
  afterEach(() => {
@@ -204,7 +212,7 @@ describe('guardApiProxyRequest', () => {
204
212
  mocks.isBlocked.mockResolvedValue(null);
205
213
  mocks.limit.mockResolvedValueOnce({
206
214
  success: false,
207
- limit: 300,
215
+ limit: 600,
208
216
  remaining: 0,
209
217
  reset: Date.now() + 15_000,
210
218
  });
@@ -221,10 +229,10 @@ describe('guardApiProxyRequest', () => {
221
229
  );
222
230
 
223
231
  expect(response?.status).toBe(429);
224
- expect(response?.headers.get('X-RateLimit-Limit')).toBe('300');
232
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('600');
225
233
  expect(response?.headers.get('X-RateLimit-Policy')).toBe('task-board-read');
226
234
  expect(mocks.ratelimitConfigs).toContainEqual({
227
- limit: 300,
235
+ limit: 600,
228
236
  window: '1 m',
229
237
  });
230
238
  expect(mocks.ratelimitPrefixes).toContain(
@@ -542,6 +550,90 @@ describe('guardApiProxyRequest', () => {
542
550
  });
543
551
  });
544
552
 
553
+ it('uses the users database read-over-post bucket for search and filter POST reads', async () => {
554
+ vi.stubEnv('NODE_ENV', 'production');
555
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
556
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
557
+ mocks.redis.mockReturnValue({});
558
+ mocks.extractIp.mockReturnValue('1.2.3.4');
559
+ mocks.isBlocked.mockResolvedValue(null);
560
+ mocks.limit.mockResolvedValue({
561
+ success: false,
562
+ limit: 300,
563
+ remaining: 0,
564
+ reset: Date.now() + 15_000,
565
+ });
566
+
567
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
568
+ await import('../api-proxy-guard.js');
569
+ clearApiProxyGuardLimiterCache();
570
+
571
+ for (const path of [
572
+ '/api/v1/workspaces/ws-1/users/database',
573
+ '/api/v1/workspaces/ws-1/users/groups/featured-counts',
574
+ '/api/v1/workspaces/ws-1/users/groups/possible-excluded',
575
+ ]) {
576
+ const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
577
+ prefixBase: 'proxy:test:api',
578
+ });
579
+
580
+ expect(response?.status).toBe(429);
581
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('300');
582
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
583
+ 'users-database-read-over-post'
584
+ );
585
+ }
586
+
587
+ expect(mocks.ratelimitConfigs).toContainEqual({
588
+ limit: 300,
589
+ window: '1 m',
590
+ });
591
+ expect(mocks.ratelimitConfigs).toContainEqual({
592
+ limit: 3000,
593
+ window: '1 h',
594
+ });
595
+ expect(mocks.ratelimitConfigs).toContainEqual({
596
+ limit: 20_000,
597
+ window: '1 d',
598
+ });
599
+ expect(mocks.ratelimitPrefixes).toContain(
600
+ 'proxy:test:api:users-database-read-over-post:anonymous:mutate:minute'
601
+ );
602
+ });
603
+
604
+ it('keeps users mutations on the default mutation bucket', async () => {
605
+ vi.stubEnv('NODE_ENV', 'production');
606
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
607
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
608
+ mocks.redis.mockReturnValue({});
609
+ mocks.extractIp.mockReturnValue('1.2.3.4');
610
+ mocks.isBlocked.mockResolvedValue(null);
611
+ mocks.limit.mockResolvedValue({
612
+ success: false,
613
+ limit: 30,
614
+ remaining: 0,
615
+ reset: Date.now() + 15_000,
616
+ });
617
+
618
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
619
+ await import('../api-proxy-guard.js');
620
+ clearApiProxyGuardLimiterCache();
621
+
622
+ const response = await guardApiProxyRequest(
623
+ makeRequest(
624
+ '/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/referrals',
625
+ 'POST'
626
+ ),
627
+ {
628
+ prefixBase: 'proxy:test:api',
629
+ }
630
+ );
631
+
632
+ expect(response?.status).toBe(429);
633
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
634
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
635
+ });
636
+
545
637
  it('keeps finance invoice mutations on the default mutation bucket', async () => {
546
638
  vi.stubEnv('NODE_ENV', 'production');
547
639
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -574,7 +666,7 @@ describe('guardApiProxyRequest', () => {
574
666
  }
575
667
 
576
668
  expect(mocks.ratelimitPrefixes).toContain(
577
- 'proxy:test:api:default:anonymous::api:v1:workspaces:ws-1:finance:invoices:mutate:minute'
669
+ 'proxy:test:api:default:anonymous:mutate:minute'
578
670
  );
579
671
  expect(mocks.ratelimitPrefixes).not.toContain(
580
672
  'proxy:test:api:finance-invoice-create-read:anonymous:mutate:minute'
@@ -931,7 +1023,7 @@ describe('guardApiProxyRequest', () => {
931
1023
  expect(mocks.limit).not.toHaveBeenCalled();
932
1024
  });
933
1025
 
934
- it('scopes anonymous read buckets by pathname to avoid cross-route collisions', async () => {
1026
+ it('shares broad anonymous read buckets across path variants', async () => {
935
1027
  vi.stubEnv('NODE_ENV', 'production');
936
1028
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
937
1029
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
@@ -952,13 +1044,28 @@ describe('guardApiProxyRequest', () => {
952
1044
  await guardApiProxyRequest(makeRequest('/api/v1/users/me/profile', 'GET'), {
953
1045
  prefixBase: 'proxy:test:api',
954
1046
  });
1047
+ await guardApiProxyRequest(
1048
+ makeRequest('/api/v1/users/me/nonexistent/random-nonce', 'GET'),
1049
+ {
1050
+ prefixBase: 'proxy:test:api',
1051
+ }
1052
+ );
955
1053
 
956
1054
  expect(mocks.ratelimitPrefixes).toContain(
1055
+ 'proxy:test:api:users-me:anonymous:get:minute'
1056
+ );
1057
+ expect(mocks.ratelimitPrefixes).not.toContain(
957
1058
  'proxy:test:api:users-me:anonymous::api:v1:users:me:profile:get:minute'
958
1059
  );
1060
+ expect(mocks.ratelimitPrefixes).not.toContain(
1061
+ 'proxy:test:api:users-me:anonymous::api:v1:users:me:nonexistent:random-nonce:get:minute'
1062
+ );
959
1063
  });
960
1064
 
961
- it('uses a classroom-friendly OTP send bucket', async () => {
1065
+ it.each([
1066
+ '/api/v1/auth/otp/send',
1067
+ '/api/v1/auth/mobile/send-otp',
1068
+ ])('uses a classroom-friendly OTP send bucket for %s', async (path) => {
962
1069
  vi.stubEnv('NODE_ENV', 'production');
963
1070
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
964
1071
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
@@ -976,12 +1083,9 @@ describe('guardApiProxyRequest', () => {
976
1083
  await import('../api-proxy-guard.js');
977
1084
  clearApiProxyGuardLimiterCache();
978
1085
 
979
- const response = await guardApiProxyRequest(
980
- makeRequest('/api/v1/auth/otp/send', 'POST'),
981
- {
982
- prefixBase: 'proxy:test:api',
983
- }
984
- );
1086
+ const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
1087
+ prefixBase: 'proxy:test:api',
1088
+ });
985
1089
 
986
1090
  expect(response?.status).toBe(429);
987
1091
  expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
@@ -1191,6 +1295,59 @@ describe('guardApiProxyRequest', () => {
1191
1295
  expect(response?.headers.get('X-RateLimit-Policy')).toBe('high-fanout');
1192
1296
  });
1193
1297
 
1298
+ it('prepends additional route policies before default high-fanout buckets', async () => {
1299
+ vi.stubEnv('NODE_ENV', 'production');
1300
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1301
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1302
+ mocks.redis.mockReturnValue({});
1303
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1304
+ mocks.isBlocked.mockResolvedValue(null);
1305
+ mocks.limit.mockResolvedValueOnce({
1306
+ success: false,
1307
+ limit: 30,
1308
+ remaining: 0,
1309
+ reset: Date.now() + 15_000,
1310
+ });
1311
+
1312
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1313
+ await import('../api-proxy-guard.js');
1314
+ clearApiProxyGuardLimiterCache();
1315
+
1316
+ const response = await guardApiProxyRequest(
1317
+ makeRequest('/api/v1/workspaces/ws-1/mail/send', 'POST'),
1318
+ {
1319
+ additionalRoutePolicies: [
1320
+ {
1321
+ key: 'email-rate-limit-override',
1322
+ matches: (req) =>
1323
+ /^\/api\/v1\/workspaces\/[^/]+\/mail\/send(?:\/|$)/.test(
1324
+ req.nextUrl.pathname
1325
+ ),
1326
+ rateLimits: {
1327
+ get: [],
1328
+ mutate: [{ duration: '1 m', limit: 30, window: 'minute' }],
1329
+ },
1330
+ },
1331
+ ],
1332
+ prefixBase: 'proxy:test:api',
1333
+ }
1334
+ );
1335
+
1336
+ expect(response?.status).toBe(429);
1337
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
1338
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
1339
+ 'email-rate-limit-override'
1340
+ );
1341
+ expect(mocks.ratelimitConfigs).toContainEqual({
1342
+ limit: 30,
1343
+ window: '1 m',
1344
+ });
1345
+ expect(mocks.ratelimitConfigs).not.toContainEqual({
1346
+ limit: 2,
1347
+ window: '1 m',
1348
+ });
1349
+ });
1350
+
1194
1351
  it('uses relaxed buckets for task description persistence routes', async () => {
1195
1352
  vi.stubEnv('NODE_ENV', 'production');
1196
1353
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -1403,6 +1560,340 @@ describe('guardApiProxyRequest', () => {
1403
1560
  expect(mocks.validateEmoji).toHaveBeenCalledTimes(1);
1404
1561
  });
1405
1562
 
1563
+ it('gives a trusted session its own per-session bucket with scaled read limits', async () => {
1564
+ vi.stubEnv('NODE_ENV', 'production');
1565
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1566
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1567
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1568
+ mocks.redis.mockReturnValue({});
1569
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1570
+ mocks.isBlocked.mockResolvedValue(null);
1571
+ // Trust the session subject key (account trust → per-session keying).
1572
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1573
+ const map = new Map<string, { m: number }>();
1574
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
1575
+ if (sessionKey) {
1576
+ map.set(sessionKey, { m: 3 });
1577
+ }
1578
+ return Promise.resolve(map);
1579
+ });
1580
+ mocks.limit.mockResolvedValueOnce({
1581
+ success: false,
1582
+ limit: 1800,
1583
+ remaining: 0,
1584
+ reset: Date.now() + 15_000,
1585
+ });
1586
+
1587
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1588
+ await import('../api-proxy-guard.js');
1589
+ clearApiProxyGuardLimiterCache();
1590
+
1591
+ const response = await guardApiProxyRequest(
1592
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
1593
+ cookie:
1594
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1595
+ }),
1596
+ { prefixBase: 'proxy:test:api' }
1597
+ );
1598
+
1599
+ expect(response?.status).toBe(429);
1600
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('task-board-read');
1601
+ // Base task-board read minute limit (600) scaled by the trust tier (3).
1602
+ expect(mocks.ratelimitConfigs).toContainEqual({
1603
+ limit: 1800,
1604
+ window: '1 m',
1605
+ });
1606
+ // Trusted traffic gets a tier-suffixed bucket.
1607
+ expect(mocks.ratelimitPrefixes).toContain(
1608
+ 'proxy:test:api:task-board-read:anonymous:t3:get:minute'
1609
+ );
1610
+ // The limiter is keyed by the per-session subject key, not the shared IP.
1611
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1612
+ expect(limiterId.startsWith('session:')).toBe(true);
1613
+ });
1614
+
1615
+ it('uplifts a trusted location per-IP without per-session keying', async () => {
1616
+ vi.stubEnv('NODE_ENV', 'production');
1617
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1618
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1619
+ mocks.redis.mockReturnValue({});
1620
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1621
+ mocks.isBlocked.mockResolvedValue(null);
1622
+ // Trust the location (cidr) subject key only — no session cookie present.
1623
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1624
+ const map = new Map<string, { m: number }>();
1625
+ const cidrKey = keys.find((key) => key.startsWith('cidr:'));
1626
+ if (cidrKey) {
1627
+ map.set(cidrKey, { m: 2 });
1628
+ }
1629
+ return Promise.resolve(map);
1630
+ });
1631
+ mocks.limit.mockResolvedValueOnce({
1632
+ success: false,
1633
+ limit: 1200,
1634
+ remaining: 0,
1635
+ reset: Date.now() + 15_000,
1636
+ });
1637
+
1638
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1639
+ await import('../api-proxy-guard.js');
1640
+ clearApiProxyGuardLimiterCache();
1641
+
1642
+ const response = await guardApiProxyRequest(
1643
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
1644
+ { prefixBase: 'proxy:test:api' }
1645
+ );
1646
+
1647
+ expect(response?.status).toBe(429);
1648
+ expect(mocks.ratelimitConfigs).toContainEqual({
1649
+ limit: 1200,
1650
+ window: '1 m',
1651
+ });
1652
+ expect(mocks.ratelimitPrefixes).toContain(
1653
+ 'proxy:test:api:task-board-read:anonymous:t2:get:minute'
1654
+ );
1655
+ // Location trust keeps the shared per-IP bucket (whole team benefits).
1656
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
1657
+ });
1658
+
1659
+ it('keeps a trusted location on per-IP keying even with an untrusted session cookie', async () => {
1660
+ vi.stubEnv('NODE_ENV', 'production');
1661
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1662
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1663
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1664
+ mocks.redis.mockReturnValue({});
1665
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1666
+ mocks.isBlocked.mockResolvedValue(null);
1667
+ // Only the cidr is trusted; the session key is absent from the cache.
1668
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1669
+ const map = new Map<string, { m: number }>();
1670
+ const cidrKey = keys.find((key) => key.startsWith('cidr:'));
1671
+ if (cidrKey) {
1672
+ map.set(cidrKey, { m: 2 });
1673
+ }
1674
+ return Promise.resolve(map);
1675
+ });
1676
+ mocks.limit.mockResolvedValueOnce({
1677
+ success: false,
1678
+ limit: 1200,
1679
+ remaining: 0,
1680
+ reset: Date.now() + 15_000,
1681
+ });
1682
+
1683
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1684
+ await import('../api-proxy-guard.js');
1685
+ clearApiProxyGuardLimiterCache();
1686
+
1687
+ await guardApiProxyRequest(
1688
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
1689
+ cookie:
1690
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1691
+ }),
1692
+ { prefixBase: 'proxy:test:api' }
1693
+ );
1694
+
1695
+ // An untrusted session must not escape the per-IP bucket.
1696
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
1697
+ });
1698
+
1699
+ it('keeps untrusted reads on the legacy per-IP bucket without trust suffix', async () => {
1700
+ vi.stubEnv('NODE_ENV', 'production');
1701
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1702
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1703
+ mocks.redis.mockReturnValue({});
1704
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1705
+ mocks.isBlocked.mockResolvedValue(null);
1706
+ // Default mock: empty trust map (forged or simply untrusted).
1707
+ mocks.limit.mockResolvedValueOnce({
1708
+ success: false,
1709
+ limit: 600,
1710
+ remaining: 0,
1711
+ reset: Date.now() + 15_000,
1712
+ });
1713
+
1714
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1715
+ await import('../api-proxy-guard.js');
1716
+ clearApiProxyGuardLimiterCache();
1717
+
1718
+ const response = await guardApiProxyRequest(
1719
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
1720
+ cookie:
1721
+ 'sb-resolved-kingfish-21146-auth-token.0=forged-value; theme=dark',
1722
+ }),
1723
+ { prefixBase: 'proxy:test:api' }
1724
+ );
1725
+
1726
+ expect(response?.status).toBe(429);
1727
+ expect(mocks.ratelimitConfigs).toContainEqual({
1728
+ limit: 600,
1729
+ window: '1 m',
1730
+ });
1731
+ expect(mocks.ratelimitPrefixes).toContain(
1732
+ 'proxy:test:api:task-board-read:anonymous:get:minute'
1733
+ );
1734
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
1735
+ });
1736
+
1737
+ it('applies an absolute read limit from a location rule', async () => {
1738
+ vi.stubEnv('NODE_ENV', 'production');
1739
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1740
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1741
+ mocks.redis.mockReturnValue({});
1742
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1743
+ mocks.isBlocked.mockResolvedValue(null);
1744
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1745
+ const map = new Map<string, unknown>();
1746
+ const cidrKey = keys.find((key) => key.startsWith('cidr:'));
1747
+ if (cidrKey) {
1748
+ map.set(cidrKey, { abs: { minute: 50 }, m: 1, mode: 'absolute' });
1749
+ }
1750
+ return Promise.resolve(map);
1751
+ });
1752
+ mocks.limit.mockResolvedValueOnce({
1753
+ success: false,
1754
+ limit: 50,
1755
+ remaining: 0,
1756
+ reset: Date.now() + 15_000,
1757
+ });
1758
+
1759
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1760
+ await import('../api-proxy-guard.js');
1761
+ clearApiProxyGuardLimiterCache();
1762
+
1763
+ const response = await guardApiProxyRequest(
1764
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
1765
+ { prefixBase: 'proxy:test:api' }
1766
+ );
1767
+
1768
+ expect(response?.status).toBe(429);
1769
+ // Absolute exactly sets the minute window (even below the base 600).
1770
+ expect(mocks.ratelimitConfigs).toContainEqual({ limit: 50, window: '1 m' });
1771
+ expect(mocks.ratelimitPrefixes).toContain(
1772
+ 'proxy:test:api:task-board-read:anonymous:abs-50-x-x:get:minute'
1773
+ );
1774
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
1775
+ });
1776
+
1777
+ it('removes the read cap for an unlimited rule', async () => {
1778
+ vi.stubEnv('NODE_ENV', 'production');
1779
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1780
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1781
+ mocks.redis.mockReturnValue({});
1782
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1783
+ mocks.isBlocked.mockResolvedValue(null);
1784
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1785
+ const map = new Map<string, unknown>();
1786
+ const cidrKey = keys.find((key) => key.startsWith('cidr:'));
1787
+ if (cidrKey) {
1788
+ map.set(cidrKey, { m: 1, mode: 'unlimited' });
1789
+ }
1790
+ return Promise.resolve(map);
1791
+ });
1792
+ mocks.validateEmoji.mockResolvedValue(null);
1793
+
1794
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1795
+ await import('../api-proxy-guard.js');
1796
+ clearApiProxyGuardLimiterCache();
1797
+
1798
+ const response = await guardApiProxyRequest(
1799
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
1800
+ { prefixBase: 'proxy:test:api' }
1801
+ );
1802
+
1803
+ // No read limiter is constructed → request passes the rate-limit stage.
1804
+ expect(response).toBeNull();
1805
+ expect(mocks.limit).not.toHaveBeenCalled();
1806
+ });
1807
+
1808
+ it('uplifts reads for a trusted workspace parsed from the path', async () => {
1809
+ vi.stubEnv('NODE_ENV', 'production');
1810
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1811
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1812
+ mocks.redis.mockReturnValue({});
1813
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1814
+ mocks.isBlocked.mockResolvedValue(null);
1815
+ const receivedKeys: string[] = [];
1816
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1817
+ receivedKeys.push(...keys);
1818
+ const map = new Map<string, unknown>();
1819
+ const workspaceKey = keys.find((key) => key.startsWith('workspace:'));
1820
+ if (workspaceKey) {
1821
+ map.set(workspaceKey, { m: 5 });
1822
+ }
1823
+ return Promise.resolve(map);
1824
+ });
1825
+ mocks.limit.mockResolvedValueOnce({
1826
+ success: false,
1827
+ limit: 3000,
1828
+ remaining: 0,
1829
+ reset: Date.now() + 15_000,
1830
+ });
1831
+
1832
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1833
+ await import('../api-proxy-guard.js');
1834
+ clearApiProxyGuardLimiterCache();
1835
+
1836
+ const wsId = '33333333-3333-4333-8333-333333333333';
1837
+ const response = await guardApiProxyRequest(
1838
+ makeRequest(`/api/v1/workspaces/${wsId}/tasks?boardId=board-1`, 'GET'),
1839
+ { prefixBase: 'proxy:test:api' }
1840
+ );
1841
+
1842
+ expect(response?.status).toBe(429);
1843
+ expect(receivedKeys).toContain(`workspace:${wsId}`);
1844
+ // Base task-board minute (600) scaled by the workspace tier (5).
1845
+ expect(mocks.ratelimitConfigs).toContainEqual({
1846
+ limit: 3000,
1847
+ window: '1 m',
1848
+ });
1849
+ expect(mocks.ratelimitPrefixes).toContain(
1850
+ 'proxy:test:api:task-board-read:anonymous:t5:get:minute'
1851
+ );
1852
+ });
1853
+
1854
+ it('derives the session subject key the same way as server-side reputation', async () => {
1855
+ vi.stubEnv('NODE_ENV', 'production');
1856
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1857
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1858
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1859
+ mocks.redis.mockReturnValue({});
1860
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1861
+ mocks.isBlocked.mockResolvedValue(null);
1862
+ const receivedKeys: string[] = [];
1863
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1864
+ receivedKeys.push(...keys);
1865
+ return Promise.resolve(new Map<string, { m: number }>());
1866
+ });
1867
+ mocks.limit.mockResolvedValue({
1868
+ success: true,
1869
+ limit: 600,
1870
+ remaining: 599,
1871
+ reset: Date.now() + 15_000,
1872
+ });
1873
+ mocks.validateEmoji.mockResolvedValue(null);
1874
+
1875
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1876
+ await import('../api-proxy-guard.js');
1877
+ clearApiProxyGuardLimiterCache();
1878
+
1879
+ const cookieName = 'sb-resolved-kingfish-21146-auth-token.0';
1880
+ const cookieValue = 'base64-validvalue';
1881
+ await guardApiProxyRequest(
1882
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
1883
+ cookie: `${cookieName}=${cookieValue}; theme=dark`,
1884
+ }),
1885
+ { prefixBase: 'proxy:test:api' }
1886
+ );
1887
+
1888
+ const { createHash } = await import('node:crypto');
1889
+ const expected = `session:${createHash('sha256')
1890
+ .update(`${cookieName}:${cookieValue}`)
1891
+ .digest('hex')
1892
+ .slice(0, 24)}`;
1893
+
1894
+ expect(receivedKeys).toContain(expected);
1895
+ });
1896
+
1406
1897
  it('skips text-bomb validation for whiteboard snapshots on the whiteboard save route', async () => {
1407
1898
  vi.stubEnv('NODE_ENV', 'production');
1408
1899
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -110,11 +110,31 @@ describe('resolveInternalAppUrl', () => {
110
110
  kind: 'internal',
111
111
  name: 'tasks',
112
112
  });
113
+ expect(
114
+ getAppDomainByUrl(
115
+ 'https://zalo-qr-chat-setup.tasks.tuturuuu.localhost:1355/verify-token?nextUrl=%2Fpersonal'
116
+ )
117
+ ).toMatchObject({
118
+ canonicalUrl:
119
+ 'https://zalo-qr-chat-setup.tasks.tuturuuu.localhost/verify-token?nextUrl=%2Fpersonal',
120
+ kind: 'internal',
121
+ name: 'tasks',
122
+ });
113
123
  expect(
114
124
  getAppDomainByUrl(
115
125
  'https://tuturuuu.localhost.evil.test:1355/verify-token?nextUrl=%2F'
116
126
  )
117
127
  ).toBeNull();
128
+ expect(
129
+ getAppDomainByUrl(
130
+ 'https://tasks.tuturuuu.localhost:4444/verify-token?nextUrl=%2Fpersonal'
131
+ )
132
+ ).toBeNull();
133
+ expect(
134
+ getAppDomainByUrl(
135
+ 'https://attacker.tasks.tuturuuu.localhost:4444/verify-token?nextUrl=%2Fpersonal'
136
+ )
137
+ ).toBeNull();
118
138
  });
119
139
 
120
140
  it('canonicalizes production internal app URLs to their registered HTTPS origins', () => {
@@ -30,12 +30,15 @@ describe('upstash-rest', () => {
30
30
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
31
31
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
32
32
  const client = {
33
+ decr: vi.fn(),
33
34
  del: vi.fn(),
34
35
  eval: vi.fn(),
35
36
  evalsha: vi.fn(),
36
37
  expire: vi.fn(),
37
38
  get: vi.fn(),
38
39
  incr: vi.fn(),
40
+ mget: vi.fn(),
41
+ scan: vi.fn(),
39
42
  set: vi.fn(),
40
43
  ttl: vi.fn(),
41
44
  };