@tuturuuu/utils 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
10
10
  isBlocked: vi.fn(),
11
11
  validateEmoji: vi.fn(),
12
12
  getTrustEntries: vi.fn(),
13
+ hasAppealRelief: vi.fn(),
13
14
  }));
14
15
 
15
16
  vi.mock('@upstash/ratelimit', () => {
@@ -53,6 +54,8 @@ vi.mock('../abuse-protection/edge', () => ({
53
54
 
54
55
  vi.mock('../abuse-protection/edge-trust', () => ({
55
56
  getCachedTrustEntries: (keys: string[]) => mocks.getTrustEntries(keys),
57
+ hasCachedIpBlockAppealRelief: (sessionKey: string, ipKey: string) =>
58
+ mocks.hasAppealRelief(sessionKey, ipKey),
56
59
  }));
57
60
 
58
61
  vi.mock('../request-emoji-limit', () => ({
@@ -104,8 +107,10 @@ describe('guardApiProxyRequest', () => {
104
107
  mocks.isBlocked.mockReset();
105
108
  mocks.validateEmoji.mockReset();
106
109
  mocks.getTrustEntries.mockReset();
110
+ mocks.hasAppealRelief.mockReset();
107
111
  // Default: no cached trust (untrusted). Tests opt in by overriding.
108
112
  mocks.getTrustEntries.mockResolvedValue(new Map<string, { m: number }>());
113
+ mocks.hasAppealRelief.mockResolvedValue(false);
109
114
  });
110
115
 
111
116
  afterEach(() => {
@@ -172,6 +177,147 @@ describe('guardApiProxyRequest', () => {
172
177
  expect(mocks.limit).not.toHaveBeenCalled();
173
178
  });
174
179
 
180
+ it('allows a blocked IP to submit the rate-limit appeal endpoint only', async () => {
181
+ vi.stubEnv('NODE_ENV', 'production');
182
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
183
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
184
+ mocks.redis.mockReturnValue({});
185
+ mocks.extractIp.mockReturnValue('1.2.3.4');
186
+ mocks.isBlocked.mockResolvedValue({
187
+ expiresAt: new Date(Date.now() + 30_000),
188
+ reason: 'abuse',
189
+ blockLevel: 'temporary',
190
+ });
191
+ mocks.limit
192
+ .mockResolvedValueOnce({
193
+ success: true,
194
+ limit: 1,
195
+ remaining: 0,
196
+ reset: Date.now() + 15_000,
197
+ })
198
+ .mockResolvedValueOnce({
199
+ success: true,
200
+ limit: 3,
201
+ remaining: 2,
202
+ reset: Date.now() + 15_000,
203
+ })
204
+ .mockResolvedValueOnce({
205
+ success: true,
206
+ limit: 10,
207
+ remaining: 9,
208
+ reset: Date.now() + 15_000,
209
+ });
210
+ mocks.validateEmoji.mockResolvedValue(null);
211
+
212
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
213
+ await import('../api-proxy-guard.js');
214
+ clearApiProxyGuardLimiterCache();
215
+
216
+ const response = await guardApiProxyRequest(
217
+ makeRequest('/api/v1/rate-limit-appeals', 'POST', {
218
+ cookie: 'tuturuuu_app_session=session-value',
219
+ }),
220
+ {
221
+ prefixBase: 'proxy:test:api',
222
+ }
223
+ );
224
+
225
+ expect(response).toBeNull();
226
+ expect(mocks.hasAppealRelief).not.toHaveBeenCalled();
227
+ expect(mocks.limit).toHaveBeenCalledTimes(3);
228
+ expect(mocks.ratelimitConfigs).toContainEqual({
229
+ limit: 1,
230
+ window: '1 m',
231
+ });
232
+ expect(mocks.ratelimitPrefixes).toContain(
233
+ 'proxy:test:api:rate-limit-appeal:anonymous:mutate:minute'
234
+ );
235
+ });
236
+
237
+ it('allows a blocked IP through normal APIs only with matching temporary session relief', async () => {
238
+ vi.stubEnv('NODE_ENV', 'production');
239
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
240
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
241
+ mocks.redis.mockReturnValue({});
242
+ mocks.extractIp.mockReturnValue('1.2.3.4');
243
+ mocks.isBlocked.mockResolvedValue({
244
+ expiresAt: new Date(Date.now() + 30_000),
245
+ reason: 'abuse',
246
+ blockLevel: 'temporary',
247
+ });
248
+ mocks.hasAppealRelief.mockResolvedValueOnce(true);
249
+ mocks.limit
250
+ .mockResolvedValueOnce({
251
+ success: true,
252
+ limit: 30,
253
+ remaining: 29,
254
+ reset: Date.now() + 15_000,
255
+ })
256
+ .mockResolvedValueOnce({
257
+ success: true,
258
+ limit: 120,
259
+ remaining: 119,
260
+ reset: Date.now() + 15_000,
261
+ })
262
+ .mockResolvedValueOnce({
263
+ success: true,
264
+ limit: 600,
265
+ remaining: 599,
266
+ reset: Date.now() + 15_000,
267
+ });
268
+ mocks.validateEmoji.mockResolvedValue(null);
269
+
270
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
271
+ await import('../api-proxy-guard.js');
272
+ clearApiProxyGuardLimiterCache();
273
+
274
+ const response = await guardApiProxyRequest(
275
+ makeRequest('/api/test', 'POST', {
276
+ cookie: 'tuturuuu_app_session=session-value',
277
+ }),
278
+ {
279
+ prefixBase: 'proxy:test:api',
280
+ }
281
+ );
282
+
283
+ expect(response).toBeNull();
284
+ expect(mocks.hasAppealRelief).toHaveBeenCalledWith(
285
+ expect.stringMatching(/^session:/),
286
+ 'ip:1.2.3.4'
287
+ );
288
+ expect(mocks.limit).toHaveBeenCalledTimes(3);
289
+ });
290
+
291
+ it('keeps unrelated blocked sessions without temporary relief blocked', async () => {
292
+ vi.stubEnv('NODE_ENV', 'production');
293
+ mocks.extractIp.mockReturnValue('1.2.3.4');
294
+ mocks.isBlocked.mockResolvedValue({
295
+ expiresAt: new Date(Date.now() + 30_000),
296
+ reason: 'abuse',
297
+ blockLevel: 'temporary',
298
+ });
299
+ mocks.hasAppealRelief.mockResolvedValue(false);
300
+
301
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
302
+ await import('../api-proxy-guard.js');
303
+ clearApiProxyGuardLimiterCache();
304
+
305
+ const response = await guardApiProxyRequest(
306
+ makeRequest('/api/test', 'POST', {
307
+ cookie: 'tuturuuu_app_session=other-session',
308
+ }),
309
+ {
310
+ prefixBase: 'proxy:test:api',
311
+ }
312
+ );
313
+
314
+ expect(response?.status).toBe(429);
315
+ expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
316
+ 'ip-already-blocked'
317
+ );
318
+ expect(mocks.limit).not.toHaveBeenCalled();
319
+ });
320
+
175
321
  it('rate limits anonymous generic GET routes', async () => {
176
322
  vi.stubEnv('NODE_ENV', 'production');
177
323
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -601,6 +747,101 @@ describe('guardApiProxyRequest', () => {
601
747
  );
602
748
  });
603
749
 
750
+ it('uses a dedicated users-admin read bucket for lightweight user reads', async () => {
751
+ vi.stubEnv('NODE_ENV', 'production');
752
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
753
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
754
+ mocks.redis.mockReturnValue({});
755
+ mocks.extractIp.mockReturnValue('1.2.3.4');
756
+ mocks.isBlocked.mockResolvedValue(null);
757
+ mocks.limit.mockResolvedValue({
758
+ success: false,
759
+ limit: 1200,
760
+ remaining: 0,
761
+ reset: Date.now() + 15_000,
762
+ });
763
+
764
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
765
+ await import('../api-proxy-guard.js');
766
+ clearApiProxyGuardLimiterCache();
767
+
768
+ for (const path of [
769
+ '/api/v1/workspaces/ws-1/users/database',
770
+ '/api/v1/workspaces/ws-1/users/groups',
771
+ '/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/attendance',
772
+ '/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/emails',
773
+ '/api/v1/workspaces/ws-1/users/11111111-1111-4111-8111-111111111111/referrals',
774
+ '/api/v1/workspaces/ws-1/users/feedbacks',
775
+ '/api/v1/workspaces/ws-1/user-groups/group-1',
776
+ '/api/v1/workspaces/ws-1/user-groups/group-1/members',
777
+ '/api/v1/workspaces/ws-1/user-groups/group-1/members/user-1/feedbacks',
778
+ '/api/v1/workspaces/ws-1/user-groups/group-1/attendance',
779
+ '/api/v1/workspaces/ws-1/user-groups/group-1/posts/post-1/status',
780
+ '/api/v1/workspaces/ws-1/user-groups/group-1/linked-products',
781
+ '/api/v1/workspaces/ws-1/user-groups/group-1/indicators/categories/category-1',
782
+ ]) {
783
+ const response = await guardApiProxyRequest(makeRequest(path, 'GET'), {
784
+ prefixBase: 'proxy:test:api',
785
+ });
786
+
787
+ expect(response?.status).toBe(429);
788
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('1200');
789
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
790
+ 'users-admin-read'
791
+ );
792
+ }
793
+
794
+ expect(mocks.ratelimitConfigs).toContainEqual({
795
+ limit: 1200,
796
+ window: '1 m',
797
+ });
798
+ expect(mocks.ratelimitConfigs).toContainEqual({
799
+ limit: 12_000,
800
+ window: '1 h',
801
+ });
802
+ expect(mocks.ratelimitConfigs).toContainEqual({
803
+ limit: 80_000,
804
+ window: '1 d',
805
+ });
806
+ expect(mocks.ratelimitPrefixes).toContain(
807
+ 'proxy:test:api:users-admin-read:anonymous:get:minute'
808
+ );
809
+ });
810
+
811
+ it('keeps user admin mutations on mutation buckets', async () => {
812
+ vi.stubEnv('NODE_ENV', 'production');
813
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
814
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
815
+ mocks.redis.mockReturnValue({});
816
+ mocks.extractIp.mockReturnValue('1.2.3.4');
817
+ mocks.isBlocked.mockResolvedValue(null);
818
+ mocks.limit.mockResolvedValue({
819
+ success: false,
820
+ limit: 30,
821
+ remaining: 0,
822
+ reset: Date.now() + 15_000,
823
+ });
824
+
825
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
826
+ await import('../api-proxy-guard.js');
827
+ clearApiProxyGuardLimiterCache();
828
+
829
+ for (const path of [
830
+ '/api/v1/workspaces/ws-1/users/feedbacks',
831
+ '/api/v1/workspaces/ws-1/user-groups/group-1/members',
832
+ '/api/v1/workspaces/ws-1/user-groups/group-1/attendance',
833
+ '/api/v1/workspaces/ws-1/user-groups/group-1/linked-products',
834
+ ]) {
835
+ const response = await guardApiProxyRequest(makeRequest(path, 'POST'), {
836
+ prefixBase: 'proxy:test:api',
837
+ });
838
+
839
+ expect(response?.status).toBe(429);
840
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
841
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
842
+ }
843
+ });
844
+
604
845
  it('keeps users mutations on the default mutation bucket', async () => {
605
846
  vi.stubEnv('NODE_ENV', 'production');
606
847
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -673,6 +914,110 @@ describe('guardApiProxyRequest', () => {
673
914
  );
674
915
  });
675
916
 
917
+ it('keys verified calendar event deletes by authenticated session', async () => {
918
+ vi.stubEnv('NODE_ENV', 'production');
919
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
920
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
921
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
922
+ mocks.redis.mockReturnValue({});
923
+ mocks.extractIp.mockReturnValue('1.2.3.4');
924
+ mocks.isBlocked.mockResolvedValue(null);
925
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
926
+ const map = new Map<string, { m: number; verified: true }>();
927
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
928
+ if (sessionKey) {
929
+ map.set(sessionKey, { m: 1, verified: true });
930
+ }
931
+ return Promise.resolve(map);
932
+ });
933
+ mocks.limit.mockResolvedValueOnce({
934
+ success: false,
935
+ limit: 60,
936
+ remaining: 0,
937
+ reset: Date.now() + 15_000,
938
+ });
939
+
940
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
941
+ await import('../api-proxy-guard.js');
942
+ clearApiProxyGuardLimiterCache();
943
+
944
+ const response = await guardApiProxyRequest(
945
+ makeRequest(
946
+ '/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
947
+ 'DELETE',
948
+ {
949
+ cookie:
950
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
951
+ }
952
+ ),
953
+ { prefixBase: 'proxy:test:api' }
954
+ );
955
+
956
+ expect(response?.status).toBe(429);
957
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
958
+ 'authenticated'
959
+ );
960
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
961
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
962
+ expect(mocks.ratelimitConfigs).toContainEqual({
963
+ limit: 60,
964
+ window: '1 m',
965
+ });
966
+ expect(mocks.ratelimitPrefixes).toContain(
967
+ 'proxy:test:api:default:authenticated:mutate:minute'
968
+ );
969
+ expect(mocks.ratelimitPrefixes).not.toContain(
970
+ 'proxy:test:api:default:authenticated:t1:mutate:minute'
971
+ );
972
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
973
+ expect(limiterId.startsWith('session:')).toBe(true);
974
+ });
975
+
976
+ it('keeps unverified calendar event deletes on the anonymous IP bucket', async () => {
977
+ vi.stubEnv('NODE_ENV', 'production');
978
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
979
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
980
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
981
+ mocks.redis.mockReturnValue({});
982
+ mocks.extractIp.mockReturnValue('1.2.3.4');
983
+ mocks.isBlocked.mockResolvedValue(null);
984
+ mocks.limit.mockResolvedValueOnce({
985
+ success: false,
986
+ limit: 30,
987
+ remaining: 0,
988
+ reset: Date.now() + 15_000,
989
+ });
990
+
991
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
992
+ await import('../api-proxy-guard.js');
993
+ clearApiProxyGuardLimiterCache();
994
+
995
+ const response = await guardApiProxyRequest(
996
+ makeRequest(
997
+ '/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
998
+ 'DELETE',
999
+ {
1000
+ cookie:
1001
+ 'sb-resolved-kingfish-21146-auth-token.0=forged-value; theme=dark',
1002
+ }
1003
+ ),
1004
+ { prefixBase: 'proxy:test:api' }
1005
+ );
1006
+
1007
+ expect(response?.status).toBe(429);
1008
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
1009
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
1010
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
1011
+ expect(mocks.ratelimitConfigs).toContainEqual({
1012
+ limit: 30,
1013
+ window: '1 m',
1014
+ });
1015
+ expect(mocks.ratelimitPrefixes).toContain(
1016
+ 'proxy:test:api:default:anonymous:mutate:minute'
1017
+ );
1018
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
1019
+ });
1020
+
676
1021
  it('keeps unauthenticated cron reads on a strict proxy bucket', async () => {
677
1022
  vi.stubEnv('NODE_ENV', 'production');
678
1023
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -699,141 +1044,333 @@ describe('guardApiProxyRequest', () => {
699
1044
  );
700
1045
 
701
1046
  expect(response?.status).toBe(429);
702
- expect(response?.headers.get('X-RateLimit-Limit')).toBe('10');
1047
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('10');
1048
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
1049
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('cron');
1050
+ });
1051
+
1052
+ it('does not grant elevated proxy budgets from authenticated-looking headers alone', async () => {
1053
+ vi.stubEnv('NODE_ENV', 'production');
1054
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1055
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1056
+ mocks.redis.mockReturnValue({});
1057
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1058
+ mocks.isBlocked.mockResolvedValue(null);
1059
+ mocks.limit.mockResolvedValueOnce({
1060
+ success: false,
1061
+ limit: 30,
1062
+ remaining: 0,
1063
+ reset: Date.now() + 15_000,
1064
+ });
1065
+ mocks.validateEmoji.mockResolvedValue(null);
1066
+
1067
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1068
+ await import('../api-proxy-guard.js');
1069
+ clearApiProxyGuardLimiterCache();
1070
+
1071
+ const response = await guardApiProxyRequest(
1072
+ makeRequest('/api/test', 'POST', {
1073
+ authorization: 'Bearer ttr_fake',
1074
+ }),
1075
+ {
1076
+ prefixBase: 'proxy:test:api',
1077
+ }
1078
+ );
1079
+
1080
+ expect(response?.status).toBe(429);
1081
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
1082
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
1083
+ expect(mocks.isBlocked).toHaveBeenCalledWith('1.2.3.4');
1084
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
1085
+ expect(mocks.validateEmoji).not.toHaveBeenCalled();
1086
+ });
1087
+
1088
+ it('uses local rate limits when the Redis rate limiter is unreachable', async () => {
1089
+ vi.stubEnv('NODE_ENV', 'production');
1090
+ vi.stubEnv(
1091
+ 'UPSTASH_REDIS_REST_URL',
1092
+ 'https://resolved-kingfish-21146.upstash.io'
1093
+ );
1094
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1095
+ mocks.redis.mockReturnValue({});
1096
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1097
+ mocks.isBlocked.mockResolvedValue(null);
1098
+ mocks.limit.mockRejectedValueOnce(new TypeError('fetch failed'));
1099
+ mocks.validateEmoji.mockResolvedValue(null);
1100
+
1101
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1102
+ await import('../api-proxy-guard.js');
1103
+ clearApiProxyGuardLimiterCache();
1104
+
1105
+ const options = {
1106
+ prefixBase: 'proxy:test:api',
1107
+ routePolicies: singleReadRoutePolicies(),
1108
+ };
1109
+
1110
+ const response = await guardApiProxyRequest(
1111
+ makeRequest('/api/test', 'GET'),
1112
+ options
1113
+ );
1114
+
1115
+ expect(response).toBeNull();
1116
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
1117
+
1118
+ const blockedResponse = await guardApiProxyRequest(
1119
+ makeRequest('/api/test', 'GET'),
1120
+ options
1121
+ );
1122
+
1123
+ expect(blockedResponse?.status).toBe(429);
1124
+ expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
1125
+ 'route-rate-limit'
1126
+ );
1127
+ expect(blockedResponse?.headers.get('X-RateLimit-Policy')).toBe(
1128
+ 'redis-fallback-test'
1129
+ );
1130
+ expect(mocks.limit).toHaveBeenCalledTimes(1);
1131
+ });
1132
+
1133
+ it('uses local rate limits when Redis client initialization fails', async () => {
1134
+ vi.stubEnv('NODE_ENV', 'production');
1135
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1136
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1137
+ mocks.redis.mockImplementation(() => {
1138
+ throw new TypeError('Redis unavailable');
1139
+ });
1140
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1141
+ mocks.isBlocked.mockResolvedValue(null);
1142
+ mocks.validateEmoji.mockResolvedValue(null);
1143
+
1144
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1145
+ await import('../api-proxy-guard.js');
1146
+ clearApiProxyGuardLimiterCache();
1147
+
1148
+ const options = {
1149
+ prefixBase: 'proxy:test:api',
1150
+ routePolicies: singleReadRoutePolicies(),
1151
+ };
1152
+
1153
+ const response = await guardApiProxyRequest(
1154
+ makeRequest('/api/test', 'GET'),
1155
+ options
1156
+ );
1157
+
1158
+ expect(response).toBeNull();
1159
+
1160
+ const blockedResponse = await guardApiProxyRequest(
1161
+ makeRequest('/api/test', 'GET'),
1162
+ options
1163
+ );
1164
+
1165
+ expect(blockedResponse?.status).toBe(429);
1166
+ expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
1167
+ 'route-rate-limit'
1168
+ );
1169
+ expect(mocks.limit).not.toHaveBeenCalled();
1170
+ });
1171
+
1172
+ it('rate limits anonymous users/me reads', async () => {
1173
+ vi.stubEnv('NODE_ENV', 'production');
1174
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1175
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1176
+ mocks.redis.mockReturnValue({});
1177
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1178
+ mocks.isBlocked.mockResolvedValue(null);
1179
+ mocks.limit.mockResolvedValueOnce({
1180
+ success: false,
1181
+ limit: 20,
1182
+ remaining: 0,
1183
+ reset: Date.now() + 15_000,
1184
+ });
1185
+
1186
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1187
+ await import('../api-proxy-guard.js');
1188
+ clearApiProxyGuardLimiterCache();
1189
+
1190
+ const response = await guardApiProxyRequest(
1191
+ makeRequest('/api/v1/users/me/configs/demo', 'GET'),
1192
+ { prefixBase: 'proxy:test:api' }
1193
+ );
1194
+
1195
+ expect(response?.status).toBe(429);
1196
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('20');
703
1197
  expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
704
- expect(response?.headers.get('X-RateLimit-Policy')).toBe('cron');
705
1198
  });
706
1199
 
707
- it('does not grant elevated proxy budgets from authenticated-looking headers alone', async () => {
1200
+ it('keys verified users/me reads by session without applying a trust uplift', async () => {
708
1201
  vi.stubEnv('NODE_ENV', 'production');
709
1202
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
710
1203
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1204
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
711
1205
  mocks.redis.mockReturnValue({});
712
1206
  mocks.extractIp.mockReturnValue('1.2.3.4');
713
1207
  mocks.isBlocked.mockResolvedValue(null);
1208
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1209
+ const map = new Map<string, { m: number; verified: true }>();
1210
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
1211
+ if (sessionKey) {
1212
+ map.set(sessionKey, { m: 1, verified: true });
1213
+ }
1214
+ return Promise.resolve(map);
1215
+ });
714
1216
  mocks.limit.mockResolvedValueOnce({
715
1217
  success: false,
716
- limit: 30,
1218
+ limit: 600,
717
1219
  remaining: 0,
718
1220
  reset: Date.now() + 15_000,
719
1221
  });
720
- mocks.validateEmoji.mockResolvedValue(null);
721
1222
 
722
1223
  const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
723
1224
  await import('../api-proxy-guard.js');
724
1225
  clearApiProxyGuardLimiterCache();
725
1226
 
726
1227
  const response = await guardApiProxyRequest(
727
- makeRequest('/api/test', 'POST', {
728
- authorization: 'Bearer ttr_fake',
1228
+ makeRequest('/api/v1/users/me/profile', 'GET', {
1229
+ cookie:
1230
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
729
1231
  }),
730
- {
731
- prefixBase: 'proxy:test:api',
732
- }
1232
+ { prefixBase: 'proxy:test:api' }
733
1233
  );
734
1234
 
735
1235
  expect(response?.status).toBe(429);
736
- expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
737
- expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
738
- expect(mocks.isBlocked).toHaveBeenCalledWith('1.2.3.4');
739
- expect(mocks.limit).toHaveBeenCalledTimes(1);
740
- expect(mocks.validateEmoji).not.toHaveBeenCalled();
1236
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
1237
+ 'authenticated'
1238
+ );
1239
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('600');
1240
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('users-me');
1241
+ expect(mocks.ratelimitConfigs).toContainEqual({
1242
+ limit: 600,
1243
+ window: '1 m',
1244
+ });
1245
+ expect(mocks.ratelimitPrefixes).toContain(
1246
+ 'proxy:test:api:users-me:authenticated:get:minute'
1247
+ );
1248
+ expect(mocks.ratelimitPrefixes).not.toContain(
1249
+ 'proxy:test:api:users-me:authenticated:t1:get:minute'
1250
+ );
1251
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1252
+ expect(limiterId.startsWith('session:')).toBe(true);
741
1253
  });
742
1254
 
743
- it('uses local rate limits when the Redis rate limiter is unreachable', async () => {
1255
+ it('uses the authenticated workspace read policy for verified dashboard reads', async () => {
744
1256
  vi.stubEnv('NODE_ENV', 'production');
745
- vi.stubEnv(
746
- 'UPSTASH_REDIS_REST_URL',
747
- 'https://resolved-kingfish-21146.upstash.io'
748
- );
1257
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
749
1258
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1259
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
750
1260
  mocks.redis.mockReturnValue({});
751
1261
  mocks.extractIp.mockReturnValue('1.2.3.4');
752
1262
  mocks.isBlocked.mockResolvedValue(null);
753
- mocks.limit.mockRejectedValueOnce(new TypeError('fetch failed'));
754
- mocks.validateEmoji.mockResolvedValue(null);
1263
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1264
+ const map = new Map<string, { m: number; verified: true }>();
1265
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
1266
+ if (sessionKey) {
1267
+ map.set(sessionKey, { m: 1, verified: true });
1268
+ }
1269
+ return Promise.resolve(map);
1270
+ });
1271
+ mocks.limit.mockResolvedValueOnce({
1272
+ success: false,
1273
+ limit: 600,
1274
+ remaining: 0,
1275
+ reset: Date.now() + 15_000,
1276
+ });
755
1277
 
756
1278
  const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
757
1279
  await import('../api-proxy-guard.js');
758
1280
  clearApiProxyGuardLimiterCache();
759
1281
 
760
- const options = {
761
- prefixBase: 'proxy:test:api',
762
- routePolicies: singleReadRoutePolicies(),
763
- };
764
-
765
1282
  const response = await guardApiProxyRequest(
766
- makeRequest('/api/test', 'GET'),
767
- options
1283
+ makeRequest('/api/v1/workspaces/ws-1/dashboard/bootstrap', 'GET', {
1284
+ cookie:
1285
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1286
+ }),
1287
+ { prefixBase: 'proxy:test:api' }
768
1288
  );
769
1289
 
770
- expect(response).toBeNull();
771
- expect(mocks.limit).toHaveBeenCalledTimes(1);
772
-
773
- const blockedResponse = await guardApiProxyRequest(
774
- makeRequest('/api/test', 'GET'),
775
- options
1290
+ expect(response?.status).toBe(429);
1291
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
1292
+ 'authenticated'
776
1293
  );
777
-
778
- expect(blockedResponse?.status).toBe(429);
779
- expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
780
- 'route-rate-limit'
1294
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
1295
+ 'workspace-dashboard-read'
781
1296
  );
782
- expect(blockedResponse?.headers.get('X-RateLimit-Policy')).toBe(
783
- 'redis-fallback-test'
1297
+ expect(mocks.ratelimitPrefixes).toContain(
1298
+ 'proxy:test:api:workspace-dashboard-read:authenticated:get:minute'
784
1299
  );
785
- expect(mocks.limit).toHaveBeenCalledTimes(1);
1300
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1301
+ expect(limiterId.startsWith('session:')).toBe(true);
786
1302
  });
787
1303
 
788
- it('uses local rate limits when Redis client initialization fails', async () => {
1304
+ it('uses authenticated default read limits for verified sessions on generic reads', async () => {
789
1305
  vi.stubEnv('NODE_ENV', 'production');
790
1306
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
791
1307
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
792
- mocks.redis.mockImplementation(() => {
793
- throw new TypeError('Redis unavailable');
794
- });
1308
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1309
+ mocks.redis.mockReturnValue({});
795
1310
  mocks.extractIp.mockReturnValue('1.2.3.4');
796
1311
  mocks.isBlocked.mockResolvedValue(null);
797
- mocks.validateEmoji.mockResolvedValue(null);
1312
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1313
+ const map = new Map<string, { m: number; verified: true }>();
1314
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
1315
+ if (sessionKey) {
1316
+ map.set(sessionKey, { m: 1, verified: true });
1317
+ }
1318
+ return Promise.resolve(map);
1319
+ });
1320
+ mocks.limit.mockResolvedValueOnce({
1321
+ success: false,
1322
+ limit: 600,
1323
+ remaining: 0,
1324
+ reset: Date.now() + 15_000,
1325
+ });
798
1326
 
799
1327
  const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
800
1328
  await import('../api-proxy-guard.js');
801
1329
  clearApiProxyGuardLimiterCache();
802
1330
 
803
- const options = {
804
- prefixBase: 'proxy:test:api',
805
- routePolicies: singleReadRoutePolicies(),
806
- };
807
-
808
1331
  const response = await guardApiProxyRequest(
809
- makeRequest('/api/test', 'GET'),
810
- options
1332
+ makeRequest('/api/v1/unknown/read-only-dashboard-fragment', 'GET', {
1333
+ cookie:
1334
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1335
+ }),
1336
+ { prefixBase: 'proxy:test:api' }
811
1337
  );
812
1338
 
813
- expect(response).toBeNull();
814
-
815
- const blockedResponse = await guardApiProxyRequest(
816
- makeRequest('/api/test', 'GET'),
817
- options
1339
+ expect(response?.status).toBe(429);
1340
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
1341
+ 'authenticated'
818
1342
  );
819
-
820
- expect(blockedResponse?.status).toBe(429);
821
- expect(blockedResponse?.headers.get('X-Proxy-Block-Reason')).toBe(
822
- 'route-rate-limit'
1343
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('600');
1344
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe('default');
1345
+ expect(mocks.ratelimitPrefixes).toContain(
1346
+ 'proxy:test:api:default:authenticated:get:minute'
823
1347
  );
824
- expect(mocks.limit).not.toHaveBeenCalled();
1348
+ expect(mocks.ratelimitPrefixes).not.toContain(
1349
+ 'proxy:test:api:default:authenticated:t1:get:minute'
1350
+ );
1351
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1352
+ expect(limiterId.startsWith('session:')).toBe(true);
825
1353
  });
826
1354
 
827
- it('rate limits anonymous users/me reads', async () => {
1355
+ it('covers legacy workspace reads with the verified workspace dashboard policy', async () => {
828
1356
  vi.stubEnv('NODE_ENV', 'production');
829
1357
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
830
1358
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1359
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
831
1360
  mocks.redis.mockReturnValue({});
832
1361
  mocks.extractIp.mockReturnValue('1.2.3.4');
833
1362
  mocks.isBlocked.mockResolvedValue(null);
1363
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
1364
+ const map = new Map<string, { m: number; verified: true }>();
1365
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
1366
+ if (sessionKey) {
1367
+ map.set(sessionKey, { m: 1, verified: true });
1368
+ }
1369
+ return Promise.resolve(map);
1370
+ });
834
1371
  mocks.limit.mockResolvedValueOnce({
835
1372
  success: false,
836
- limit: 20,
1373
+ limit: 600,
837
1374
  remaining: 0,
838
1375
  reset: Date.now() + 15_000,
839
1376
  });
@@ -843,13 +1380,25 @@ describe('guardApiProxyRequest', () => {
843
1380
  clearApiProxyGuardLimiterCache();
844
1381
 
845
1382
  const response = await guardApiProxyRequest(
846
- makeRequest('/api/v1/users/me/configs/demo', 'GET'),
1383
+ makeRequest('/api/workspaces/ws-1/settings', 'GET', {
1384
+ cookie:
1385
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1386
+ }),
847
1387
  { prefixBase: 'proxy:test:api' }
848
1388
  );
849
1389
 
850
1390
  expect(response?.status).toBe(429);
851
- expect(response?.headers.get('X-RateLimit-Limit')).toBe('20');
852
- expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
1391
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
1392
+ 'authenticated'
1393
+ );
1394
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
1395
+ 'workspace-dashboard-read'
1396
+ );
1397
+ expect(mocks.ratelimitPrefixes).toContain(
1398
+ 'proxy:test:api:workspace-dashboard-read:authenticated:get:minute'
1399
+ );
1400
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1401
+ expect(limiterId.startsWith('session:')).toBe(true);
853
1402
  });
854
1403
 
855
1404
  it('rate limits API client-looking bearer reads until route auth validates them', async () => {
@@ -1603,15 +2152,73 @@ describe('guardApiProxyRequest', () => {
1603
2152
  limit: 1800,
1604
2153
  window: '1 m',
1605
2154
  });
1606
- // Trusted traffic gets a tier-suffixed bucket.
2155
+ // Trusted traffic gets an authenticated, tier-suffixed bucket.
1607
2156
  expect(mocks.ratelimitPrefixes).toContain(
1608
- 'proxy:test:api:task-board-read:anonymous:t3:get:minute'
2157
+ 'proxy:test:api:task-board-read:authenticated:t3:get:minute'
1609
2158
  );
1610
2159
  // The limiter is keyed by the per-session subject key, not the shared IP.
1611
2160
  const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1612
2161
  expect(limiterId.startsWith('session:')).toBe(true);
1613
2162
  });
1614
2163
 
2164
+ it('keeps trusted-session mutation limits flat while using the session bucket', async () => {
2165
+ vi.stubEnv('NODE_ENV', 'production');
2166
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2167
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2168
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
2169
+ mocks.redis.mockReturnValue({});
2170
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2171
+ mocks.isBlocked.mockResolvedValue(null);
2172
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
2173
+ const map = new Map<string, { m: number }>();
2174
+ const sessionKey = keys.find((key) => key.startsWith('session:'));
2175
+ if (sessionKey) {
2176
+ map.set(sessionKey, { m: 3 });
2177
+ }
2178
+ return Promise.resolve(map);
2179
+ });
2180
+ mocks.limit.mockResolvedValueOnce({
2181
+ success: false,
2182
+ limit: 60,
2183
+ remaining: 0,
2184
+ reset: Date.now() + 15_000,
2185
+ });
2186
+
2187
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2188
+ await import('../api-proxy-guard.js');
2189
+ clearApiProxyGuardLimiterCache();
2190
+
2191
+ const response = await guardApiProxyRequest(
2192
+ makeRequest(
2193
+ '/api/v1/workspaces/071e0fc7-9aa8-42d8-92e5-cc9b3aeec2f1/calendar/events/68e18a3a-c527-421e-8ac3-f8b846175622',
2194
+ 'DELETE',
2195
+ {
2196
+ cookie:
2197
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
2198
+ }
2199
+ ),
2200
+ { prefixBase: 'proxy:test:api' }
2201
+ );
2202
+
2203
+ expect(response?.status).toBe(429);
2204
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
2205
+ 'authenticated'
2206
+ );
2207
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('60');
2208
+ expect(mocks.ratelimitConfigs).toContainEqual({
2209
+ limit: 60,
2210
+ window: '1 m',
2211
+ });
2212
+ expect(mocks.ratelimitPrefixes).toContain(
2213
+ 'proxy:test:api:default:authenticated:mutate:minute'
2214
+ );
2215
+ expect(mocks.ratelimitPrefixes).not.toContain(
2216
+ 'proxy:test:api:default:authenticated:t3:mutate:minute'
2217
+ );
2218
+ const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
2219
+ expect(limiterId.startsWith('session:')).toBe(true);
2220
+ });
2221
+
1615
2222
  it('uplifts a trusted location per-IP without per-session keying', async () => {
1616
2223
  vi.stubEnv('NODE_ENV', 'production');
1617
2224
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -1656,6 +2263,56 @@ describe('guardApiProxyRequest', () => {
1656
2263
  expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
1657
2264
  });
1658
2265
 
2266
+ it('does not apply trusted-location read uplift to mutations', async () => {
2267
+ vi.stubEnv('NODE_ENV', 'production');
2268
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
2269
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
2270
+ mocks.redis.mockReturnValue({});
2271
+ mocks.extractIp.mockReturnValue('1.2.3.4');
2272
+ mocks.isBlocked.mockResolvedValue(null);
2273
+ const receivedKeys: string[] = [];
2274
+ mocks.getTrustEntries.mockImplementation((keys: string[]) => {
2275
+ receivedKeys.push(...keys);
2276
+ const map = new Map<string, { m: number }>();
2277
+ const cidrKey = keys.find((key) => key.startsWith('cidr:'));
2278
+ if (cidrKey) {
2279
+ map.set(cidrKey, { m: 2 });
2280
+ }
2281
+ return Promise.resolve(map);
2282
+ });
2283
+ mocks.limit.mockResolvedValueOnce({
2284
+ success: false,
2285
+ limit: 30,
2286
+ remaining: 0,
2287
+ reset: Date.now() + 15_000,
2288
+ });
2289
+
2290
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
2291
+ await import('../api-proxy-guard.js');
2292
+ clearApiProxyGuardLimiterCache();
2293
+
2294
+ const response = await guardApiProxyRequest(
2295
+ makeRequest('/api/v1/workspaces/ws-1/tasks', 'POST'),
2296
+ { prefixBase: 'proxy:test:api' }
2297
+ );
2298
+
2299
+ expect(response?.status).toBe(429);
2300
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
2301
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('30');
2302
+ expect(mocks.ratelimitConfigs).toContainEqual({
2303
+ limit: 30,
2304
+ window: '1 m',
2305
+ });
2306
+ expect(mocks.ratelimitPrefixes).toContain(
2307
+ 'proxy:test:api:default:anonymous:mutate:minute'
2308
+ );
2309
+ expect(mocks.ratelimitPrefixes).not.toContain(
2310
+ 'proxy:test:api:default:anonymous:t2:mutate:minute'
2311
+ );
2312
+ expect(receivedKeys.some((key) => key.startsWith('cidr:'))).toBe(false);
2313
+ expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
2314
+ });
2315
+
1659
2316
  it('keeps a trusted location on per-IP keying even with an untrusted session cookie', async () => {
1660
2317
  vi.stubEnv('NODE_ENV', 'production');
1661
2318
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');