@tuturuuu/utils 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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(
@@ -593,6 +601,101 @@ describe('guardApiProxyRequest', () => {
593
601
  );
594
602
  });
595
603
 
604
+ it('uses a dedicated users-admin read bucket for lightweight user reads', 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: 1200,
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
+ for (const path of [
623
+ '/api/v1/workspaces/ws-1/users/database',
624
+ '/api/v1/workspaces/ws-1/users/groups',
625
+ '/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/attendance',
626
+ '/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/emails',
627
+ '/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/referrals',
628
+ '/api/v1/workspaces/ws-1/users/feedbacks',
629
+ '/api/v1/workspaces/ws-1/user-groups/group-1',
630
+ '/api/v1/workspaces/ws-1/user-groups/group-1/members',
631
+ '/api/v1/workspaces/ws-1/user-groups/group-1/members/user-1/feedbacks',
632
+ '/api/v1/workspaces/ws-1/user-groups/group-1/attendance',
633
+ '/api/v1/workspaces/ws-1/user-groups/group-1/posts/post-1/status',
634
+ '/api/v1/workspaces/ws-1/user-groups/group-1/linked-products',
635
+ '/api/v1/workspaces/ws-1/user-groups/group-1/indicators/categories/category-1',
636
+ ]) {
637
+ const response = await guardApiProxyRequest(makeRequest(path, 'GET'), {
638
+ prefixBase: 'proxy:test:api',
639
+ });
640
+
641
+ expect(response?.status).toBe(429);
642
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('1200');
643
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
644
+ 'users-admin-read'
645
+ );
646
+ }
647
+
648
+ expect(mocks.ratelimitConfigs).toContainEqual({
649
+ limit: 1200,
650
+ window: '1 m',
651
+ });
652
+ expect(mocks.ratelimitConfigs).toContainEqual({
653
+ limit: 12_000,
654
+ window: '1 h',
655
+ });
656
+ expect(mocks.ratelimitConfigs).toContainEqual({
657
+ limit: 80_000,
658
+ window: '1 d',
659
+ });
660
+ expect(mocks.ratelimitPrefixes).toContain(
661
+ 'proxy:test:api:users-admin-read:anonymous:get:minute'
662
+ );
663
+ });
664
+
665
+ it('keeps user admin mutations on mutation buckets', async () => {
666
+ vi.stubEnv('NODE_ENV', 'production');
667
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
668
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
669
+ mocks.redis.mockReturnValue({});
670
+ mocks.extractIp.mockReturnValue('1.2.3.4');
671
+ mocks.isBlocked.mockResolvedValue(null);
672
+ mocks.limit.mockResolvedValue({
673
+ success: false,
674
+ limit: 30,
675
+ remaining: 0,
676
+ reset: Date.now() + 15_000,
677
+ });
678
+
679
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
680
+ await import('../api-proxy-guard.js');
681
+ clearApiProxyGuardLimiterCache();
682
+
683
+ for (const path of [
684
+ '/api/v1/workspaces/ws-1/users/feedbacks',
685
+ '/api/v1/workspaces/ws-1/user-groups/group-1/members',
686
+ '/api/v1/workspaces/ws-1/user-groups/group-1/attendance',
687
+ '/api/v1/workspaces/ws-1/user-groups/group-1/linked-products',
688
+ ]) {
689
+ const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
690
+ prefixBase: 'proxy:test:api',
691
+ });
692
+
693
+ expect(response?.status).toBe(429);
694
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
695
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
696
+ }
697
+ });
698
+
596
699
  it('keeps users mutations on the default mutation bucket', async () => {
597
700
  vi.stubEnv('NODE_ENV', 'production');
598
701
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -658,13 +761,117 @@ describe('guardApiProxyRequest', () => {
658
761
  }
659
762
 
660
763
  expect(mocks.ratelimitPrefixes).toContain(
661
- 'proxy:test:api:default:anonymous::api:v1:workspaces:ws-1:finance:invoices:mutate:minute'
764
+ 'proxy:test:api:default:anonymous:mutate:minute'
662
765
  );
663
766
  expect(mocks.ratelimitPrefixes).not.toContain(
664
767
  'proxy:test:api:finance-invoice-create-read:anonymous:mutate:minute'
665
768
  );
666
769
  });
667
770
 
771
+ it('keys verified calendar event deletes by authenticated session', async () => {
772
+ vi.stubEnv('NODE_ENV', 'production');
773
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
774
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
775
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
776
+ mocks.redis.mockReturnValue({});
777
+ mocks.extractIp.mockReturnValue('1.2.3.4');
778
+ mocks.isBlocked.mockResolvedValue(null);
779
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
780
+ const map = new Map<string, { m: number; verified: true }>();
781
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
782
+ if (sessionKey) {
783
+ map.set(sessionKey, { m: 1, verified: true });
784
+ }
785
+ return Promise.resolve(map);
786
+ });
787
+ mocks.limit.mockResolvedValueOnce({
788
+ success: false,
789
+ limit: 60,
790
+ remaining: 0,
791
+ reset: Date.now() + 15_000,
792
+ });
793
+
794
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
795
+ await import('../api-proxy-guard.js');
796
+ clearApiProxyGuardLimiterCache();
797
+
798
+ const response = await guardApiProxyRequest(
799
+ makeRequest(
800
+ '/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
801
+ 'DELETE',
802
+ {
803
+ cookie:
804
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
805
+ }
806
+ ),
807
+ { prefixBase: 'proxy:test:api' }
808
+ );
809
+
810
+ expect(response?.status).toBe(429);
811
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
812
+ 'authenticated'
813
+ );
814
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
815
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
816
+ expect(mocks.ratelimitConfigs).toContainEqual({
817
+ limit: 60,
818
+ window: '1 m',
819
+ });
820
+ expect(mocks.ratelimitPrefixes).toContain(
821
+ 'proxy:test:api:default:authenticated:mutate:minute'
822
+ );
823
+ expect(mocks.ratelimitPrefixes).not.toContain(
824
+ 'proxy:test:api:default:authenticated:t1:mutate:minute'
825
+ );
826
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
827
+ expect(limiterId.startsWith('session:')).toBe(true);
828
+ });
829
+
830
+ it('keeps unverified calendar event deletes on the anonymous IP bucket', async () => {
831
+ vi.stubEnv('NODE_ENV', 'production');
832
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
833
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
834
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
835
+ mocks.redis.mockReturnValue({});
836
+ mocks.extractIp.mockReturnValue('1.2.3.4');
837
+ mocks.isBlocked.mockResolvedValue(null);
838
+ mocks.limit.mockResolvedValueOnce({
839
+ success: false,
840
+ limit: 30,
841
+ remaining: 0,
842
+ reset: Date.now() + 15_000,
843
+ });
844
+
845
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
846
+ await import('../api-proxy-guard.js');
847
+ clearApiProxyGuardLimiterCache();
848
+
849
+ const response = await guardApiProxyRequest(
850
+ makeRequest(
851
+ '/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
852
+ 'DELETE',
853
+ {
854
+ cookie:
855
+ 'sb-resolved-kingfish-21146-auth-token.0=forged-value; theme=dark',
856
+ }
857
+ ),
858
+ { prefixBase: 'proxy:test:api' }
859
+ );
860
+
861
+ expect(response?.status).toBe(429);
862
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
863
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
864
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
865
+ expect(mocks.ratelimitConfigs).toContainEqual({
866
+ limit: 30,
867
+ window: '1 m',
868
+ });
869
+ expect(mocks.ratelimitPrefixes).toContain(
870
+ 'proxy:test:api:default:anonymous:mutate:minute'
871
+ );
872
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
873
+ });
874
+
668
875
  it('keeps unauthenticated cron reads on a strict proxy bucket', async () => {
669
876
  vi.stubEnv('NODE_ENV', 'production');
670
877
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -844,16 +1051,25 @@ describe('guardApiProxyRequest', () => {
844
1051
  expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
845
1052
  });
846
1053
 
847
- it('rate limits API client-looking bearer reads until route auth validates them', async () => {
1054
+ it('keys verified users/me reads by session without applying a trust uplift', async () => {
848
1055
  vi.stubEnv('NODE_ENV', 'production');
849
1056
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
850
1057
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1058
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
851
1059
  mocks.redis.mockReturnValue({});
852
1060
  mocks.extractIp.mockReturnValue('1.2.3.4');
853
1061
  mocks.isBlocked.mockResolvedValue(null);
1062
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1063
+ const map = new Map<string, { m: number; verified: true }>();
1064
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
1065
+ if (sessionKey) {
1066
+ map.set(sessionKey, { m: 1, verified: true });
1067
+ }
1068
+ return Promise.resolve(map);
1069
+ });
854
1070
  mocks.limit.mockResolvedValueOnce({
855
1071
  success: false,
856
- limit: 20,
1072
+ limit: 600,
857
1073
  remaining: 0,
858
1074
  reset: Date.now() + 15_000,
859
1075
  });
@@ -863,18 +1079,34 @@ describe('guardApiProxyRequest', () => {
863
1079
  clearApiProxyGuardLimiterCache();
864
1080
 
865
1081
  const response = await guardApiProxyRequest(
866
- makeRequest('/api/v1/workspaces/ws-1/users/database?page=1', 'GET', {
867
- authorization: 'Bearer ttr_test_key',
1082
+ makeRequest('/api/v1/users/me/profile', 'GET', {
1083
+ cookie:
1084
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
868
1085
  }),
869
1086
  { prefixBase: 'proxy:test:api' }
870
1087
  );
871
1088
 
872
1089
  expect(response?.status).toBe(429);
873
- expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
874
- expect(mocks.limit).toHaveBeenCalledTimes(1);
1090
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
1091
+ 'authenticated'
1092
+ );
1093
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('600');
1094
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('users-me');
1095
+ expect(mocks.ratelimitConfigs).toContainEqual({
1096
+ limit: 600,
1097
+ window: '1 m',
1098
+ });
1099
+ expect(mocks.ratelimitPrefixes).toContain(
1100
+ 'proxy:test:api:users-me:authenticated:get:minute'
1101
+ );
1102
+ expect(mocks.ratelimitPrefixes).not.toContain(
1103
+ 'proxy:test:api:users-me:authenticated:t1:get:minute'
1104
+ );
1105
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1106
+ expect(limiterId.startsWith('session:')).toBe(true);
875
1107
  });
876
1108
 
877
- it('rate limits Supabase-cookie-looking reads until route auth validates them', async () => {
1109
+ it('uses the authenticated workspace read policy for verified dashboard reads', async () => {
878
1110
  vi.stubEnv('NODE_ENV', 'production');
879
1111
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
880
1112
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
@@ -882,9 +1114,17 @@ describe('guardApiProxyRequest', () => {
882
1114
  mocks.redis.mockReturnValue({});
883
1115
  mocks.extractIp.mockReturnValue('1.2.3.4');
884
1116
  mocks.isBlocked.mockResolvedValue(null);
1117
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1118
+ const map = new Map<string, { m: number; verified: true }>();
1119
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
1120
+ if (sessionKey) {
1121
+ map.set(sessionKey, { m: 1, verified: true });
1122
+ }
1123
+ return Promise.resolve(map);
1124
+ });
885
1125
  mocks.limit.mockResolvedValueOnce({
886
1126
  success: false,
887
- limit: 20,
1127
+ limit: 600,
888
1128
  remaining: 0,
889
1129
  reset: Date.now() + 15_000,
890
1130
  });
@@ -894,7 +1134,7 @@ describe('guardApiProxyRequest', () => {
894
1134
  clearApiProxyGuardLimiterCache();
895
1135
 
896
1136
  const response = await guardApiProxyRequest(
897
- makeRequest('/api/v1/users/me/profile', 'GET', {
1137
+ makeRequest('/api/v1/workspaces/ws-1/dashboard/bootstrap', 'GET', {
898
1138
  cookie:
899
1139
  'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
900
1140
  }),
@@ -902,22 +1142,38 @@ describe('guardApiProxyRequest', () => {
902
1142
  );
903
1143
 
904
1144
  expect(response?.status).toBe(429);
905
- expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
906
- expect(mocks.limit).toHaveBeenCalledTimes(1);
1145
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
1146
+ 'authenticated'
1147
+ );
1148
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
1149
+ 'workspace-dashboard-read'
1150
+ );
1151
+ expect(mocks.ratelimitPrefixes).toContain(
1152
+ 'proxy:test:api:workspace-dashboard-read:authenticated:get:minute'
1153
+ );
1154
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1155
+ expect(limiterId.startsWith('session:')).toBe(true);
907
1156
  });
908
1157
 
909
- it('rate limits server-url Supabase-cookie-looking reads until route auth validates them', async () => {
1158
+ it('uses authenticated default read limits for verified sessions on generic reads', async () => {
910
1159
  vi.stubEnv('NODE_ENV', 'production');
911
1160
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
912
1161
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
913
- vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'http://127.0.0.1:8001');
914
- vi.stubEnv('SUPABASE_SERVER_URL', 'http://host.docker.internal:8001/');
1162
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
915
1163
  mocks.redis.mockReturnValue({});
916
1164
  mocks.extractIp.mockReturnValue('1.2.3.4');
917
1165
  mocks.isBlocked.mockResolvedValue(null);
1166
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1167
+ const map = new Map<string, { m: number; verified: true }>();
1168
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
1169
+ if (sessionKey) {
1170
+ map.set(sessionKey, { m: 1, verified: true });
1171
+ }
1172
+ return Promise.resolve(map);
1173
+ });
918
1174
  mocks.limit.mockResolvedValueOnce({
919
1175
  success: false,
920
- limit: 20,
1176
+ limit: 600,
921
1177
  remaining: 0,
922
1178
  reset: Date.now() + 15_000,
923
1179
  });
@@ -927,27 +1183,48 @@ describe('guardApiProxyRequest', () => {
927
1183
  clearApiProxyGuardLimiterCache();
928
1184
 
929
1185
  const response = await guardApiProxyRequest(
930
- makeRequest('/api/v1/users/me/configs/demo', 'GET', {
931
- cookie: 'sb-host-auth-token=base64-validvalue; theme=dark',
1186
+ makeRequest('/api/v1/unknown/read-only-dashboard-fragment', 'GET', {
1187
+ cookie:
1188
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
932
1189
  }),
933
1190
  { prefixBase: 'proxy:test:api' }
934
1191
  );
935
1192
 
936
1193
  expect(response?.status).toBe(429);
937
- expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
938
- expect(mocks.limit).toHaveBeenCalledTimes(1);
1194
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
1195
+ 'authenticated'
1196
+ );
1197
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('600');
1198
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
1199
+ expect(mocks.ratelimitPrefixes).toContain(
1200
+ 'proxy:test:api:default:authenticated:get:minute'
1201
+ );
1202
+ expect(mocks.ratelimitPrefixes).not.toContain(
1203
+ 'proxy:test:api:default:authenticated:t1:get:minute'
1204
+ );
1205
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1206
+ expect(limiterId.startsWith('session:')).toBe(true);
939
1207
  });
940
1208
 
941
- it('rate limits app-session-cookie-looking satellite traffic until route auth validates it', async () => {
1209
+ it('covers legacy workspace reads with the verified workspace dashboard policy', async () => {
942
1210
  vi.stubEnv('NODE_ENV', 'production');
943
1211
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
944
1212
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1213
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
945
1214
  mocks.redis.mockReturnValue({});
946
1215
  mocks.extractIp.mockReturnValue('1.2.3.4');
947
1216
  mocks.isBlocked.mockResolvedValue(null);
1217
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1218
+ const map = new Map<string, { m: number; verified: true }>();
1219
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
1220
+ if (sessionKey) {
1221
+ map.set(sessionKey, { m: 1, verified: true });
1222
+ }
1223
+ return Promise.resolve(map);
1224
+ });
948
1225
  mocks.limit.mockResolvedValueOnce({
949
1226
  success: false,
950
- limit: 20,
1227
+ limit: 600,
951
1228
  remaining: 0,
952
1229
  reset: Date.now() + 15_000,
953
1230
  });
@@ -957,43 +1234,39 @@ describe('guardApiProxyRequest', () => {
957
1234
  clearApiProxyGuardLimiterCache();
958
1235
 
959
1236
  const response = await guardApiProxyRequest(
960
- makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
961
- cookie: 'tuturuuu_app_session=app-session-token; theme=dark',
1237
+ makeRequest('/api/workspaces/ws-1/settings', 'GET', {
1238
+ cookie:
1239
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
962
1240
  }),
963
- { prefixBase: 'proxy:tasks:api' }
1241
+ { prefixBase: 'proxy:test:api' }
964
1242
  );
965
1243
 
966
1244
  expect(response?.status).toBe(429);
967
- expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
968
- expect(mocks.limit).toHaveBeenCalledTimes(1);
969
- });
970
-
971
- it('does not classify raw app-session cookies as authenticated sessions', async () => {
972
- const { hasAuthenticatedApiSession } = await import(
973
- '../api-proxy-guard.js'
1245
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
1246
+ 'authenticated'
974
1247
  );
975
-
976
- expect(
977
- hasAuthenticatedApiSession(
978
- makeRequest('/api/v1/workspaces/ws-1/tasks', 'GET', {
979
- cookie: 'tuturuuu_app_session=anything; theme=dark',
980
- })
981
- )
982
- ).toBe(false);
1248
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
1249
+ 'workspace-dashboard-read'
1250
+ );
1251
+ expect(mocks.ratelimitPrefixes).toContain(
1252
+ 'proxy:test:api:workspace-dashboard-read:authenticated:get:minute'
1253
+ );
1254
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1255
+ expect(limiterId.startsWith('session:')).toBe(true);
983
1256
  });
984
1257
 
985
- it('enforces anonymous IP blocks for cookie-looking browser requests', async () => {
1258
+ it('rate limits API client-looking bearer reads until route auth validates them', async () => {
986
1259
  vi.stubEnv('NODE_ENV', 'production');
987
1260
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
988
1261
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
989
- vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
990
1262
  mocks.redis.mockReturnValue({});
991
1263
  mocks.extractIp.mockReturnValue('1.2.3.4');
992
- mocks.isBlocked.mockResolvedValue({
993
- expiresAt: new Date(Date.now() + 30_000),
994
- reason: 'api_abuse',
995
- blockLevel: 1,
996
- blockedAt: new Date(),
1264
+ mocks.isBlocked.mockResolvedValue(null);
1265
+ mocks.limit.mockResolvedValueOnce({
1266
+ success: false,
1267
+ limit: 20,
1268
+ remaining: 0,
1269
+ reset: Date.now() + 15_000,
997
1270
  });
998
1271
 
999
1272
  const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
@@ -1002,20 +1275,158 @@ describe('guardApiProxyRequest', () => {
1002
1275
 
1003
1276
  const response = await guardApiProxyRequest(
1004
1277
  makeRequest('/api/v1/workspaces/ws-1/users/database?page=1', 'GET', {
1005
- cookie:
1006
- 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1278
+ authorization: 'Bearer ttr_test_key',
1007
1279
  }),
1008
1280
  { prefixBase: 'proxy:test:api' }
1009
1281
  );
1010
1282
 
1011
1283
  expect(response?.status).toBe(429);
1012
- expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
1013
- 'ip-already-blocked'
1284
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
1285
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
1286
+ });
1287
+
1288
+ it('rate limits Supabase-cookie-looking reads until route auth validates them', async () => {
1289
+ vi.stubEnv('NODE_ENV', 'production');
1290
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1291
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1292
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1293
+ mocks.redis.mockReturnValue({});
1294
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1295
+ mocks.isBlocked.mockResolvedValue(null);
1296
+ mocks.limit.mockResolvedValueOnce({
1297
+ success: false,
1298
+ limit: 20,
1299
+ remaining: 0,
1300
+ reset: Date.now() + 15_000,
1301
+ });
1302
+
1303
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1304
+ await import('../api-proxy-guard.js');
1305
+ clearApiProxyGuardLimiterCache();
1306
+
1307
+ const response = await guardApiProxyRequest(
1308
+ makeRequest('/api/v1/users/me/profile', 'GET', {
1309
+ cookie:
1310
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1311
+ }),
1312
+ { prefixBase: 'proxy:test:api' }
1313
+ );
1314
+
1315
+ expect(response?.status).toBe(429);
1316
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
1317
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
1318
+ });
1319
+
1320
+ it('rate limits server-url Supabase-cookie-looking reads until route auth validates them', async () => {
1321
+ vi.stubEnv('NODE_ENV', 'production');
1322
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1323
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1324
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', 'http://127.0.0.1:8001');
1325
+ vi.stubEnv('SUPABASE_SERVER_URL', 'http://host.docker.internal:8001/');
1326
+ mocks.redis.mockReturnValue({});
1327
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1328
+ mocks.isBlocked.mockResolvedValue(null);
1329
+ mocks.limit.mockResolvedValueOnce({
1330
+ success: false,
1331
+ limit: 20,
1332
+ remaining: 0,
1333
+ reset: Date.now() + 15_000,
1334
+ });
1335
+
1336
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1337
+ await import('../api-proxy-guard.js');
1338
+ clearApiProxyGuardLimiterCache();
1339
+
1340
+ const response = await guardApiProxyRequest(
1341
+ makeRequest('/api/v1/users/me/configs/demo', 'GET', {
1342
+ cookie: 'sb-host-auth-token=base64-validvalue; theme=dark',
1343
+ }),
1344
+ { prefixBase: 'proxy:test:api' }
1345
+ );
1346
+
1347
+ expect(response?.status).toBe(429);
1348
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
1349
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
1350
+ });
1351
+
1352
+ it('rate limits app-session-cookie-looking satellite traffic until route auth validates it', async () => {
1353
+ vi.stubEnv('NODE_ENV', 'production');
1354
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1355
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1356
+ mocks.redis.mockReturnValue({});
1357
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1358
+ mocks.isBlocked.mockResolvedValue(null);
1359
+ mocks.limit.mockResolvedValueOnce({
1360
+ success: false,
1361
+ limit: 20,
1362
+ remaining: 0,
1363
+ reset: Date.now() + 15_000,
1364
+ });
1365
+
1366
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1367
+ await import('../api-proxy-guard.js');
1368
+ clearApiProxyGuardLimiterCache();
1369
+
1370
+ const response = await guardApiProxyRequest(
1371
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
1372
+ cookie: 'tuturuuu_app_session=app-session-token; theme=dark',
1373
+ }),
1374
+ { prefixBase: 'proxy:tasks:api' }
1375
+ );
1376
+
1377
+ expect(response?.status).toBe(429);
1378
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
1379
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
1380
+ });
1381
+
1382
+ it('does not classify raw app-session cookies as authenticated sessions', async () => {
1383
+ const { hasAuthenticatedApiSession } = await import(
1384
+ '../api-proxy-guard.js'
1385
+ );
1386
+
1387
+ expect(
1388
+ hasAuthenticatedApiSession(
1389
+ makeRequest('/api/v1/workspaces/ws-1/tasks', 'GET', {
1390
+ cookie: 'tuturuuu_app_session=anything; theme=dark',
1391
+ })
1392
+ )
1393
+ ).toBe(false);
1394
+ });
1395
+
1396
+ it('enforces anonymous IP blocks for cookie-looking browser requests', async () => {
1397
+ vi.stubEnv('NODE_ENV', 'production');
1398
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1399
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1400
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1401
+ mocks.redis.mockReturnValue({});
1402
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1403
+ mocks.isBlocked.mockResolvedValue({
1404
+ expiresAt: new Date(Date.now() + 30_000),
1405
+ reason: 'api_abuse',
1406
+ blockLevel: 1,
1407
+ blockedAt: new Date(),
1408
+ });
1409
+
1410
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1411
+ await import('../api-proxy-guard.js');
1412
+ clearApiProxyGuardLimiterCache();
1413
+
1414
+ const response = await guardApiProxyRequest(
1415
+ makeRequest('/api/v1/workspaces/ws-1/users/database?page=1', 'GET', {
1416
+ cookie:
1417
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1418
+ }),
1419
+ { prefixBase: 'proxy:test:api' }
1420
+ );
1421
+
1422
+ expect(response?.status).toBe(429);
1423
+ expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
1424
+ 'ip-already-blocked'
1014
1425
  );
1015
1426
  expect(mocks.limit).not.toHaveBeenCalled();
1016
1427
  });
1017
1428
 
1018
- it('scopes anonymous read buckets by pathname to avoid cross-route collisions', async () => {
1429
+ it('shares broad anonymous read buckets across path variants', async () => {
1019
1430
  vi.stubEnv('NODE_ENV', 'production');
1020
1431
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1021
1432
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
@@ -1036,13 +1447,28 @@ describe('guardApiProxyRequest', () => {
1036
1447
  await guardApiProxyRequest(makeRequest('/api/v1/users/me/profile', 'GET'), {
1037
1448
  prefixBase: 'proxy:test:api',
1038
1449
  });
1450
+ await guardApiProxyRequest(
1451
+ makeRequest('/api/v1/users/me/nonexistent/random-nonce', 'GET'),
1452
+ {
1453
+ prefixBase: 'proxy:test:api',
1454
+ }
1455
+ );
1039
1456
 
1040
1457
  expect(mocks.ratelimitPrefixes).toContain(
1458
+ 'proxy:test:api:users-me:anonymous:get:minute'
1459
+ );
1460
+ expect(mocks.ratelimitPrefixes).not.toContain(
1041
1461
  'proxy:test:api:users-me:anonymous::api:v1:users:me:profile:get:minute'
1042
1462
  );
1463
+ expect(mocks.ratelimitPrefixes).not.toContain(
1464
+ 'proxy:test:api:users-me:anonymous::api:v1:users:me:nonexistent:random-nonce:get:minute'
1465
+ );
1043
1466
  });
1044
1467
 
1045
- it('uses a classroom-friendly OTP send bucket', async () => {
1468
+ it.each([
1469
+ '/api/v1/auth/otp/send',
1470
+ '/api/v1/auth/mobile/send-otp',
1471
+ ])('uses a classroom-friendly OTP send bucket for %s', async (path) => {
1046
1472
  vi.stubEnv('NODE_ENV', 'production');
1047
1473
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1048
1474
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
@@ -1060,12 +1486,9 @@ describe('guardApiProxyRequest', () => {
1060
1486
  await import('../api-proxy-guard.js');
1061
1487
  clearApiProxyGuardLimiterCache();
1062
1488
 
1063
- const response = await guardApiProxyRequest(
1064
- makeRequest('/api/v1/auth/otp/send', 'POST'),
1065
- {
1066
- prefixBase: 'proxy:test:api',
1067
- }
1068
- );
1489
+ const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
1490
+ prefixBase: 'proxy:test:api',
1491
+ });
1069
1492
 
1070
1493
  expect(response?.status).toBe(429);
1071
1494
  expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
@@ -1275,6 +1698,59 @@ describe('guardApiProxyRequest', () => {
1275
1698
  expect(response?.headers.get('X-RateLimit-Policy')).toBe('high-fanout');
1276
1699
  });
1277
1700
 
1701
+ it('prepends additional route policies before default high-fanout buckets', async () => {
1702
+ vi.stubEnv('NODE_ENV', 'production');
1703
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1704
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1705
+ mocks.redis.mockReturnValue({});
1706
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1707
+ mocks.isBlocked.mockResolvedValue(null);
1708
+ mocks.limit.mockResolvedValueOnce({
1709
+ success: false,
1710
+ limit: 30,
1711
+ remaining: 0,
1712
+ reset: Date.now() + 15_000,
1713
+ });
1714
+
1715
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1716
+ await import('../api-proxy-guard.js');
1717
+ clearApiProxyGuardLimiterCache();
1718
+
1719
+ const response = await guardApiProxyRequest(
1720
+ makeRequest('/api/v1/workspaces/ws-1/mail/send', 'POST'),
1721
+ {
1722
+ additionalRoutePolicies: [
1723
+ {
1724
+ key: 'email-rate-limit-override',
1725
+ matches: (req) =>
1726
+ /^\/api\/v1\/workspaces\/[^/]+\/mail\/send(?:\/|$)/.test(
1727
+ req.nextUrl.pathname
1728
+ ),
1729
+ rateLimits: {
1730
+ get: [],
1731
+ mutate: [{ duration: '1 m', limit: 30, window: 'minute' }],
1732
+ },
1733
+ },
1734
+ ],
1735
+ prefixBase: 'proxy:test:api',
1736
+ }
1737
+ );
1738
+
1739
+ expect(response?.status).toBe(429);
1740
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
1741
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
1742
+ 'email-rate-limit-override'
1743
+ );
1744
+ expect(mocks.ratelimitConfigs).toContainEqual({
1745
+ limit: 30,
1746
+ window: '1 m',
1747
+ });
1748
+ expect(mocks.ratelimitConfigs).not.toContainEqual({
1749
+ limit: 2,
1750
+ window: '1 m',
1751
+ });
1752
+ });
1753
+
1278
1754
  it('uses relaxed buckets for task description persistence routes', async () => {
1279
1755
  vi.stubEnv('NODE_ENV', 'production');
1280
1756
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -1487,6 +1963,448 @@ describe('guardApiProxyRequest', () => {
1487
1963
  expect(mocks.validateEmoji).toHaveBeenCalledTimes(1);
1488
1964
  });
1489
1965
 
1966
+ it('gives a trusted session its own per-session bucket with scaled read limits', async () => {
1967
+ vi.stubEnv('NODE_ENV', 'production');
1968
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1969
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1970
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1971
+ mocks.redis.mockReturnValue({});
1972
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1973
+ mocks.isBlocked.mockResolvedValue(null);
1974
+ // Trust the session subject key (account trust → per-session keying).
1975
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1976
+ const map = new Map<string, { m: number }>();
1977
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
1978
+ if (sessionKey) {
1979
+ map.set(sessionKey, { m: 3 });
1980
+ }
1981
+ return Promise.resolve(map);
1982
+ });
1983
+ mocks.limit.mockResolvedValueOnce({
1984
+ success: false,
1985
+ limit: 1800,
1986
+ remaining: 0,
1987
+ reset: Date.now() + 15_000,
1988
+ });
1989
+
1990
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1991
+ await import('../api-proxy-guard.js');
1992
+ clearApiProxyGuardLimiterCache();
1993
+
1994
+ const response = await guardApiProxyRequest(
1995
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
1996
+ cookie:
1997
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1998
+ }),
1999
+ { prefixBase: 'proxy:test:api' }
2000
+ );
2001
+
2002
+ expect(response?.status).toBe(429);
2003
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('task-board-read');
2004
+ // Base task-board read minute limit (600) scaled by the trust tier (3).
2005
+ expect(mocks.ratelimitConfigs).toContainEqual({
2006
+ limit: 1800,
2007
+ window: '1 m',
2008
+ });
2009
+ // Trusted traffic gets an authenticated, tier-suffixed bucket.
2010
+ expect(mocks.ratelimitPrefixes).toContain(
2011
+ 'proxy:test:api:task-board-read:authenticated:t3:get:minute'
2012
+ );
2013
+ // The limiter is keyed by the per-session subject key, not the shared IP.
2014
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
2015
+ expect(limiterId.startsWith('session:')).toBe(true);
2016
+ });
2017
+
2018
+ it('keeps trusted-session mutation limits flat while using the session bucket', async () => {
2019
+ vi.stubEnv('NODE_ENV', 'production');
2020
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2021
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2022
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
2023
+ mocks.redis.mockReturnValue({});
2024
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2025
+ mocks.isBlocked.mockResolvedValue(null);
2026
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
2027
+ const map = new Map<string, { m: number }>();
2028
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
2029
+ if (sessionKey) {
2030
+ map.set(sessionKey, { m: 3 });
2031
+ }
2032
+ return Promise.resolve(map);
2033
+ });
2034
+ mocks.limit.mockResolvedValueOnce({
2035
+ success: false,
2036
+ limit: 60,
2037
+ remaining: 0,
2038
+ reset: Date.now() + 15_000,
2039
+ });
2040
+
2041
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2042
+ await import('../api-proxy-guard.js');
2043
+ clearApiProxyGuardLimiterCache();
2044
+
2045
+ const response = await guardApiProxyRequest(
2046
+ makeRequest(
2047
+ '/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
2048
+ 'DELETE',
2049
+ {
2050
+ cookie:
2051
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
2052
+ }
2053
+ ),
2054
+ { prefixBase: 'proxy:test:api' }
2055
+ );
2056
+
2057
+ expect(response?.status).toBe(429);
2058
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
2059
+ 'authenticated'
2060
+ );
2061
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
2062
+ expect(mocks.ratelimitConfigs).toContainEqual({
2063
+ limit: 60,
2064
+ window: '1 m',
2065
+ });
2066
+ expect(mocks.ratelimitPrefixes).toContain(
2067
+ 'proxy:test:api:default:authenticated:mutate:minute'
2068
+ );
2069
+ expect(mocks.ratelimitPrefixes).not.toContain(
2070
+ 'proxy:test:api:default:authenticated:t3:mutate:minute'
2071
+ );
2072
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
2073
+ expect(limiterId.startsWith('session:')).toBe(true);
2074
+ });
2075
+
2076
+ it('uplifts a trusted location per-IP without per-session keying', async () => {
2077
+ vi.stubEnv('NODE_ENV', 'production');
2078
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2079
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2080
+ mocks.redis.mockReturnValue({});
2081
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2082
+ mocks.isBlocked.mockResolvedValue(null);
2083
+ // Trust the location (cidr) subject key only — no session cookie present.
2084
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
2085
+ const map = new Map<string, { m: number }>();
2086
+ const cidrKey = keys.find((key) => key.startsWith('cidr:'));
2087
+ if (cidrKey) {
2088
+ map.set(cidrKey, { m: 2 });
2089
+ }
2090
+ return Promise.resolve(map);
2091
+ });
2092
+ mocks.limit.mockResolvedValueOnce({
2093
+ success: false,
2094
+ limit: 1200,
2095
+ remaining: 0,
2096
+ reset: Date.now() + 15_000,
2097
+ });
2098
+
2099
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2100
+ await import('../api-proxy-guard.js');
2101
+ clearApiProxyGuardLimiterCache();
2102
+
2103
+ const response = await guardApiProxyRequest(
2104
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
2105
+ { prefixBase: 'proxy:test:api' }
2106
+ );
2107
+
2108
+ expect(response?.status).toBe(429);
2109
+ expect(mocks.ratelimitConfigs).toContainEqual({
2110
+ limit: 1200,
2111
+ window: '1 m',
2112
+ });
2113
+ expect(mocks.ratelimitPrefixes).toContain(
2114
+ 'proxy:test:api:task-board-read:anonymous:t2:get:minute'
2115
+ );
2116
+ // Location trust keeps the shared per-IP bucket (whole team benefits).
2117
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
2118
+ });
2119
+
2120
+ it('does not apply trusted-location read uplift to mutations', async () => {
2121
+ vi.stubEnv('NODE_ENV', 'production');
2122
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2123
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2124
+ mocks.redis.mockReturnValue({});
2125
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2126
+ mocks.isBlocked.mockResolvedValue(null);
2127
+ const receivedKeys: string[] = [];
2128
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
2129
+ receivedKeys.push(...keys);
2130
+ const map = new Map<string, { m: number }>();
2131
+ const cidrKey = keys.find((key) => key.startsWith('cidr:'));
2132
+ if (cidrKey) {
2133
+ map.set(cidrKey, { m: 2 });
2134
+ }
2135
+ return Promise.resolve(map);
2136
+ });
2137
+ mocks.limit.mockResolvedValueOnce({
2138
+ success: false,
2139
+ limit: 30,
2140
+ remaining: 0,
2141
+ reset: Date.now() + 15_000,
2142
+ });
2143
+
2144
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2145
+ await import('../api-proxy-guard.js');
2146
+ clearApiProxyGuardLimiterCache();
2147
+
2148
+ const response = await guardApiProxyRequest(
2149
+ makeRequest('/api/v1/workspaces/ws-1/tasks', 'POST'),
2150
+ { prefixBase: 'proxy:test:api' }
2151
+ );
2152
+
2153
+ expect(response?.status).toBe(429);
2154
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
2155
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
2156
+ expect(mocks.ratelimitConfigs).toContainEqual({
2157
+ limit: 30,
2158
+ window: '1 m',
2159
+ });
2160
+ expect(mocks.ratelimitPrefixes).toContain(
2161
+ 'proxy:test:api:default:anonymous:mutate:minute'
2162
+ );
2163
+ expect(mocks.ratelimitPrefixes).not.toContain(
2164
+ 'proxy:test:api:default:anonymous:t2:mutate:minute'
2165
+ );
2166
+ expect(receivedKeys.some((key) => key.startsWith('cidr:'))).toBe(false);
2167
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
2168
+ });
2169
+
2170
+ it('keeps a trusted location on per-IP keying even with an untrusted session cookie', async () => {
2171
+ vi.stubEnv('NODE_ENV', 'production');
2172
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2173
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2174
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
2175
+ mocks.redis.mockReturnValue({});
2176
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2177
+ mocks.isBlocked.mockResolvedValue(null);
2178
+ // Only the cidr is trusted; the session key is absent from the cache.
2179
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
2180
+ const map = new Map<string, { m: number }>();
2181
+ const cidrKey = keys.find((key) => key.startsWith('cidr:'));
2182
+ if (cidrKey) {
2183
+ map.set(cidrKey, { m: 2 });
2184
+ }
2185
+ return Promise.resolve(map);
2186
+ });
2187
+ mocks.limit.mockResolvedValueOnce({
2188
+ success: false,
2189
+ limit: 1200,
2190
+ remaining: 0,
2191
+ reset: Date.now() + 15_000,
2192
+ });
2193
+
2194
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2195
+ await import('../api-proxy-guard.js');
2196
+ clearApiProxyGuardLimiterCache();
2197
+
2198
+ await guardApiProxyRequest(
2199
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
2200
+ cookie:
2201
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
2202
+ }),
2203
+ { prefixBase: 'proxy:test:api' }
2204
+ );
2205
+
2206
+ // An untrusted session must not escape the per-IP bucket.
2207
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
2208
+ });
2209
+
2210
+ it('keeps untrusted reads on the legacy per-IP bucket without trust suffix', async () => {
2211
+ vi.stubEnv('NODE_ENV', 'production');
2212
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2213
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2214
+ mocks.redis.mockReturnValue({});
2215
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2216
+ mocks.isBlocked.mockResolvedValue(null);
2217
+ // Default mock: empty trust map (forged or simply untrusted).
2218
+ mocks.limit.mockResolvedValueOnce({
2219
+ success: false,
2220
+ limit: 600,
2221
+ remaining: 0,
2222
+ reset: Date.now() + 15_000,
2223
+ });
2224
+
2225
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2226
+ await import('../api-proxy-guard.js');
2227
+ clearApiProxyGuardLimiterCache();
2228
+
2229
+ const response = await guardApiProxyRequest(
2230
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
2231
+ cookie:
2232
+ 'sb-resolved-kingfish-21146-auth-token.0=forged-value; theme=dark',
2233
+ }),
2234
+ { prefixBase: 'proxy:test:api' }
2235
+ );
2236
+
2237
+ expect(response?.status).toBe(429);
2238
+ expect(mocks.ratelimitConfigs).toContainEqual({
2239
+ limit: 600,
2240
+ window: '1 m',
2241
+ });
2242
+ expect(mocks.ratelimitPrefixes).toContain(
2243
+ 'proxy:test:api:task-board-read:anonymous:get:minute'
2244
+ );
2245
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
2246
+ });
2247
+
2248
+ it('applies an absolute read limit from a location rule', async () => {
2249
+ vi.stubEnv('NODE_ENV', 'production');
2250
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2251
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2252
+ mocks.redis.mockReturnValue({});
2253
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2254
+ mocks.isBlocked.mockResolvedValue(null);
2255
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
2256
+ const map = new Map<string, unknown>();
2257
+ const cidrKey = keys.find((key) => key.startsWith('cidr:'));
2258
+ if (cidrKey) {
2259
+ map.set(cidrKey, { abs: { minute: 50 }, m: 1, mode: 'absolute' });
2260
+ }
2261
+ return Promise.resolve(map);
2262
+ });
2263
+ mocks.limit.mockResolvedValueOnce({
2264
+ success: false,
2265
+ limit: 50,
2266
+ remaining: 0,
2267
+ reset: Date.now() + 15_000,
2268
+ });
2269
+
2270
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2271
+ await import('../api-proxy-guard.js');
2272
+ clearApiProxyGuardLimiterCache();
2273
+
2274
+ const response = await guardApiProxyRequest(
2275
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
2276
+ { prefixBase: 'proxy:test:api' }
2277
+ );
2278
+
2279
+ expect(response?.status).toBe(429);
2280
+ // Absolute exactly sets the minute window (even below the base 600).
2281
+ expect(mocks.ratelimitConfigs).toContainEqual({ limit: 50, window: '1 m' });
2282
+ expect(mocks.ratelimitPrefixes).toContain(
2283
+ 'proxy:test:api:task-board-read:anonymous:abs-50-x-x:get:minute'
2284
+ );
2285
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
2286
+ });
2287
+
2288
+ it('removes the read cap for an unlimited rule', async () => {
2289
+ vi.stubEnv('NODE_ENV', 'production');
2290
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2291
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2292
+ mocks.redis.mockReturnValue({});
2293
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2294
+ mocks.isBlocked.mockResolvedValue(null);
2295
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
2296
+ const map = new Map<string, unknown>();
2297
+ const cidrKey = keys.find((key) => key.startsWith('cidr:'));
2298
+ if (cidrKey) {
2299
+ map.set(cidrKey, { m: 1, mode: 'unlimited' });
2300
+ }
2301
+ return Promise.resolve(map);
2302
+ });
2303
+ mocks.validateEmoji.mockResolvedValue(null);
2304
+
2305
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2306
+ await import('../api-proxy-guard.js');
2307
+ clearApiProxyGuardLimiterCache();
2308
+
2309
+ const response = await guardApiProxyRequest(
2310
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET'),
2311
+ { prefixBase: 'proxy:test:api' }
2312
+ );
2313
+
2314
+ // No read limiter is constructed → request passes the rate-limit stage.
2315
+ expect(response).toBeNull();
2316
+ expect(mocks.limit).not.toHaveBeenCalled();
2317
+ });
2318
+
2319
+ it('uplifts reads for a trusted workspace parsed from the path', async () => {
2320
+ vi.stubEnv('NODE_ENV', 'production');
2321
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2322
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2323
+ mocks.redis.mockReturnValue({});
2324
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2325
+ mocks.isBlocked.mockResolvedValue(null);
2326
+ const receivedKeys: string[] = [];
2327
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
2328
+ receivedKeys.push(...keys);
2329
+ const map = new Map<string, unknown>();
2330
+ const workspaceKey = keys.find((key) => key.startsWith('workspace:'));
2331
+ if (workspaceKey) {
2332
+ map.set(workspaceKey, { m: 5 });
2333
+ }
2334
+ return Promise.resolve(map);
2335
+ });
2336
+ mocks.limit.mockResolvedValueOnce({
2337
+ success: false,
2338
+ limit: 3000,
2339
+ remaining: 0,
2340
+ reset: Date.now() + 15_000,
2341
+ });
2342
+
2343
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2344
+ await import('../api-proxy-guard.js');
2345
+ clearApiProxyGuardLimiterCache();
2346
+
2347
+ const wsId = '33333333-3333-4333-8333-333333333333';
2348
+ const response = await guardApiProxyRequest(
2349
+ makeRequest(`/api/v1/workspaces/${wsId}/tasks?boardId=board-1`, 'GET'),
2350
+ { prefixBase: 'proxy:test:api' }
2351
+ );
2352
+
2353
+ expect(response?.status).toBe(429);
2354
+ expect(receivedKeys).toContain(`workspace:${wsId}`);
2355
+ // Base task-board minute (600) scaled by the workspace tier (5).
2356
+ expect(mocks.ratelimitConfigs).toContainEqual({
2357
+ limit: 3000,
2358
+ window: '1 m',
2359
+ });
2360
+ expect(mocks.ratelimitPrefixes).toContain(
2361
+ 'proxy:test:api:task-board-read:anonymous:t5:get:minute'
2362
+ );
2363
+ });
2364
+
2365
+ it('derives the session subject key the same way as server-side reputation', async () => {
2366
+ vi.stubEnv('NODE_ENV', 'production');
2367
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2368
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2369
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
2370
+ mocks.redis.mockReturnValue({});
2371
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2372
+ mocks.isBlocked.mockResolvedValue(null);
2373
+ const receivedKeys: string[] = [];
2374
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
2375
+ receivedKeys.push(...keys);
2376
+ return Promise.resolve(new Map<string, { m: number }>());
2377
+ });
2378
+ mocks.limit.mockResolvedValue({
2379
+ success: true,
2380
+ limit: 600,
2381
+ remaining: 599,
2382
+ reset: Date.now() + 15_000,
2383
+ });
2384
+ mocks.validateEmoji.mockResolvedValue(null);
2385
+
2386
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2387
+ await import('../api-proxy-guard.js');
2388
+ clearApiProxyGuardLimiterCache();
2389
+
2390
+ const cookieName = 'sb-resolved-kingfish-21146-auth-token.0';
2391
+ const cookieValue = 'base64-validvalue';
2392
+ await guardApiProxyRequest(
2393
+ makeRequest('/api/v1/workspaces/ws-1/tasks?boardId=board-1', 'GET', {
2394
+ cookie: `${cookieName}=${cookieValue}; theme=dark`,
2395
+ }),
2396
+ { prefixBase: 'proxy:test:api' }
2397
+ );
2398
+
2399
+ const { createHash } = await import('node:crypto');
2400
+ const expected = `session:${createHash('sha256')
2401
+ .update(`${cookieName}:${cookieValue}`)
2402
+ .digest('hex')
2403
+ .slice(0, 24)}`;
2404
+
2405
+ expect(receivedKeys).toContain(expected);
2406
+ });
2407
+
1490
2408
  it('skips text-bomb validation for whiteboard snapshots on the whiteboard save route', async () => {
1491
2409
  vi.stubEnv('NODE_ENV', 'production');
1492
2410
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');