@tuturuuu/utils 0.7.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.0](https://github.com/tutur3u/platform/compare/utils-v0.7.0...utils-v0.8.0) (2026-06-17)
4
+
5
+
6
+ ### Features
7
+
8
+ * **inventory:** store commerce money in minor units and harden Polar sync ([3f7ee1d](https://github.com/tutur3u/platform/commit/3f7ee1da9335732854037e7e79fca9d5d2a381d0))
9
+ * **web:** rate limits admin center ([fe61dc9](https://github.com/tutur3u/platform/commit/fe61dc933615466f6169a3db3a4a1840f705e80b))
10
+ * **web:** trust-gated per-session read limits and trusted-location uplift ([3e64c89](https://github.com/tutur3u/platform/commit/3e64c8929dbe7987ad83900620a25a9559fe1edf))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **api:** share broad anonymous rate buckets ([04c1dc6](https://github.com/tutur3u/platform/commit/04c1dc616bf6a909b37f798c994aba41ff27d13a))
16
+ * **api:** throttle users database post reads ([698639d](https://github.com/tutur3u/platform/commit/698639d572375a67b850d533e584aa05733dc961))
17
+ * **auth:** preserve password failure pressure ([cf1e970](https://github.com/tutur3u/platform/commit/cf1e970a0f8acbf314cbdccd7abc126535dbd846))
18
+ * **auth:** reserve otp send rate limits ([42012b9](https://github.com/tutur3u/platform/commit/42012b90c75f1041637b1d3ea21ff464c942c6d9))
19
+ * **auth:** restrict Portless return ports ([7977bfc](https://github.com/tutur3u/platform/commit/7977bfc074a211a20b3bf133fe78cf94ff8c1d33))
20
+ * **ci:** format storefront files and harden upstash scan wrapper ([4c82444](https://github.com/tutur3u/platform/commit/4c824446725dc280edde3e54f946bf386a52952d))
21
+ * **ci:** unblock turbopack proxy build, type-safe upstash, format ([42393f8](https://github.com/tutur3u/platform/commit/42393f8519f6695df44f71b8654ba86237296ffe))
22
+ * **email:** keep override traffic in proxy guard ([dd6ed5a](https://github.com/tutur3u/platform/commit/dd6ed5a9a21f536e22f7abbbeb3e83f44a81cdff))
23
+ * **next:** remove loopback image defaults ([9ab8fb9](https://github.com/tutur3u/platform/commit/9ab8fb9489a613e72302d5baf35e44f8cea5c82a))
24
+ * **web:** stop new-workspace setup from trapping users on "Preparing Workspace" ([3f7ee1d](https://github.com/tutur3u/platform/commit/3f7ee1da9335732854037e7e79fca9d5d2a381d0))
25
+
26
+
27
+ ### Performance Improvements
28
+
29
+ * **web:** hydrate user-group detail pages and remove fetch waterfalls ([4845cf7](https://github.com/tutur3u/platform/commit/4845cf71cc7e3e321bd6558ba4e20a406e9cd731))
30
+
3
31
  ## [0.7.0](https://github.com/tutur3u/platform/compare/utils-v0.6.1...utils-v0.7.0) (2026-06-15)
4
32
 
5
33
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/utils",
3
3
  "license": "MIT",
4
- "version": "0.7.0",
4
+ "version": "0.8.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -24,8 +24,8 @@
24
24
  "@tiptap/react": "3.26.1",
25
25
  "@tuturuuu/google": "0.0.2",
26
26
  "@tuturuuu/icons": "0.0.6",
27
- "@tuturuuu/internal-api": "0.8.0",
28
- "@tuturuuu/supabase": "0.3.3",
27
+ "@tuturuuu/internal-api": "0.9.0",
28
+ "@tuturuuu/supabase": "0.3.4",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
30
  "@upstash/redis": "^1.38.0",
31
31
  "clsx": "^2.1.1",
@@ -44,7 +44,7 @@
44
44
  "zod": "^4.4.3"
45
45
  },
46
46
  "devDependencies": {
47
- "@tuturuuu/types": "0.9.0",
47
+ "@tuturuuu/types": "0.10.0",
48
48
  "@tuturuuu/typescript-config": "0.1.1",
49
49
  "@types/diff": "^8.0.0",
50
50
  "@types/node": "^25.9.3",
@@ -66,6 +66,7 @@
66
66
  "./abuse-protection": "./src/abuse-protection/index.ts",
67
67
  "./abuse-protection/backend-rate-limit": "./src/abuse-protection/backend-rate-limit.ts",
68
68
  "./abuse-protection/edge": "./src/abuse-protection/edge.ts",
69
+ "./abuse-protection/edge-trust": "./src/abuse-protection/edge-trust.ts",
69
70
  "./abuse-protection/user-suspension": "./src/abuse-protection/user-suspension.ts",
70
71
  "./currencies": "./src/currencies.ts",
71
72
  "./exchange-rates": "./src/exchange-rates.ts",
@@ -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(
@@ -658,7 +666,7 @@ describe('guardApiProxyRequest', () => {
658
666
  }
659
667
 
660
668
  expect(mocks.ratelimitPrefixes).toContain(
661
- 'proxy:test:api:default:anonymous::api:v1:workspaces:ws-1:finance:invoices:mutate:minute'
669
+ 'proxy:test:api:default:anonymous:mutate:minute'
662
670
  );
663
671
  expect(mocks.ratelimitPrefixes).not.toContain(
664
672
  'proxy:test:api:finance-invoice-create-read:anonymous:mutate:minute'
@@ -1015,7 +1023,7 @@ describe('guardApiProxyRequest', () => {
1015
1023
  expect(mocks.limit).not.toHaveBeenCalled();
1016
1024
  });
1017
1025
 
1018
- it('scopes anonymous read buckets by pathname to avoid cross-route collisions', async () => {
1026
+ it('shares broad anonymous read buckets across path variants', async () => {
1019
1027
  vi.stubEnv('NODE_ENV', 'production');
1020
1028
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1021
1029
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
@@ -1036,13 +1044,28 @@ describe('guardApiProxyRequest', () => {
1036
1044
  await guardApiProxyRequest(makeRequest('/api/v1/users/me/profile', 'GET'), {
1037
1045
  prefixBase: 'proxy:test:api',
1038
1046
  });
1047
+ await guardApiProxyRequest(
1048
+ makeRequest('/api/v1/users/me/nonexistent/random-nonce', 'GET'),
1049
+ {
1050
+ prefixBase: 'proxy:test:api',
1051
+ }
1052
+ );
1039
1053
 
1040
1054
  expect(mocks.ratelimitPrefixes).toContain(
1055
+ 'proxy:test:api:users-me:anonymous:get:minute'
1056
+ );
1057
+ expect(mocks.ratelimitPrefixes).not.toContain(
1041
1058
  'proxy:test:api:users-me:anonymous::api:v1:users:me:profile:get:minute'
1042
1059
  );
1060
+ expect(mocks.ratelimitPrefixes).not.toContain(
1061
+ 'proxy:test:api:users-me:anonymous::api:v1:users:me:nonexistent:random-nonce:get:minute'
1062
+ );
1043
1063
  });
1044
1064
 
1045
- 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) => {
1046
1069
  vi.stubEnv('NODE_ENV', 'production');
1047
1070
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1048
1071
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
@@ -1060,12 +1083,9 @@ describe('guardApiProxyRequest', () => {
1060
1083
  await import('../api-proxy-guard.js');
1061
1084
  clearApiProxyGuardLimiterCache();
1062
1085
 
1063
- const response = await guardApiProxyRequest(
1064
- makeRequest('/api/v1/auth/otp/send', 'POST'),
1065
- {
1066
- prefixBase: 'proxy:test:api',
1067
- }
1068
- );
1086
+ const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
1087
+ prefixBase: 'proxy:test:api',
1088
+ });
1069
1089
 
1070
1090
  expect(response?.status).toBe(429);
1071
1091
  expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
@@ -1275,6 +1295,59 @@ describe('guardApiProxyRequest', () => {
1275
1295
  expect(response?.headers.get('X-RateLimit-Policy')).toBe('high-fanout');
1276
1296
  });
1277
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
+
1278
1351
  it('uses relaxed buckets for task description persistence routes', async () => {
1279
1352
  vi.stubEnv('NODE_ENV', 'production');
1280
1353
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -1487,6 +1560,340 @@ describe('guardApiProxyRequest', () => {
1487
1560
  expect(mocks.validateEmoji).toHaveBeenCalledTimes(1);
1488
1561
  });
1489
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
+
1490
1897
  it('skips text-bomb validation for whiteboard snapshots on the whiteboard save route', async () => {
1491
1898
  vi.stubEnv('NODE_ENV', 'production');
1492
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
  };