@tuturuuu/utils 0.8.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.9.0](https://github.com/tutur3u/platform/compare/utils-v0.8.0...utils-v0.9.0) (2026-06-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * add tanstack rust migration foundation ([da7e58a](https://github.com/tutur3u/platform/commit/da7e58a2a6c7eefff5df74859e991872b5195132))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **tasks:** refresh kanban special columns ([3317e1d](https://github.com/tutur3u/platform/commit/3317e1d80890afa05a4d10840131b8f2d77910ec))
14
+ * **utils:** prevent shared IP hard bans ([a898502](https://github.com/tutur3u/platform/commit/a898502468dbc9151ad8cb12a3aafae265349ec1))
15
+ * **web:** key verified mutations by session ([caea8ea](https://github.com/tutur3u/platform/commit/caea8ea647ceff9dd63d719312cad4fcc7beee1d))
16
+
3
17
  ## [0.8.0](https://github.com/tutur3u/platform/compare/utils-v0.7.0...utils-v0.8.0) (2026-06-17)
4
18
 
5
19
 
package/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "root": false,
3
- "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
3
+ "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
4
4
  "extends": "//"
5
5
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/utils",
3
3
  "license": "MIT",
4
- "version": "0.8.0",
4
+ "version": "0.9.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -13,49 +13,51 @@
13
13
  "scripts": {
14
14
  "test": "vitest run",
15
15
  "test:watch": "vitest",
16
- "type-check": "tsgo --project tsconfig.typecheck.json"
16
+ "type-check": "tsc --project tsconfig.typecheck.json"
17
17
  },
18
18
  "dependencies": {
19
19
  "@dnd-kit/core": "^6.3.1",
20
20
  "@noble/ciphers": "^2.2.0",
21
21
  "@noble/hashes": "^2.2.0",
22
- "@tanstack/react-query": "^5.101.0",
23
- "@tiptap/core": "3.26.1",
24
- "@tiptap/react": "3.26.1",
22
+ "@tanstack/react-query": "^5.101.1",
23
+ "@tiptap/core": "3.27.1",
24
+ "@tiptap/react": "3.27.1",
25
25
  "@tuturuuu/google": "0.0.2",
26
26
  "@tuturuuu/icons": "0.0.6",
27
- "@tuturuuu/internal-api": "0.9.0",
28
- "@tuturuuu/supabase": "0.3.4",
27
+ "@tuturuuu/internal-api": "0.10.0",
28
+ "@tuturuuu/supabase": "0.4.0",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
30
  "@upstash/redis": "^1.38.0",
31
31
  "clsx": "^2.1.1",
32
32
  "date-fns": "^4.4.0",
33
33
  "dayjs": "^1.11.20",
34
34
  "diff": "^9.0.0",
35
+ "effect": "^3.21.4",
35
36
  "moment": "^2.30.1",
36
37
  "next": "^16.2.9",
37
- "prosemirror-model": "^1.25.8",
38
+ "prosemirror-model": "^1.25.9",
38
39
  "react": "^19.2.7",
39
40
  "react-dom": "^19.2.7",
40
41
  "tailwind-merge": "^3.6.0",
41
- "uuid": "^14.0.0",
42
+ "uuid": "^14.0.1",
42
43
  "y-prosemirror": "^1.3.7",
43
44
  "yjs": "^13.6.31",
44
45
  "zod": "^4.4.3"
45
46
  },
46
47
  "devDependencies": {
47
- "@tuturuuu/types": "0.10.0",
48
+ "@tuturuuu/types": "0.11.0",
48
49
  "@tuturuuu/typescript-config": "0.1.1",
49
50
  "@types/diff": "^8.0.0",
50
- "@types/node": "^25.9.3",
51
+ "@types/node": "^26.0.0",
51
52
  "@types/react": "^19.2.17",
52
- "@vitejs/plugin-react": "^6.0.2",
53
- "typescript": "^6.0.3",
54
- "vite": "^8.0.16",
55
- "vitest": "^4.1.8"
53
+ "@vitejs/plugin-react": "^6.0.3",
54
+ "typescript": "7.0.1-rc",
55
+ "vite": "^8.1.0",
56
+ "vitest": "^4.1.9"
56
57
  },
57
58
  "exports": {
58
59
  "./timezones": "./src/timezones.json",
60
+ "./effect": "./src/effect.ts",
59
61
  "./email/validation": "./src/email/validation.ts",
60
62
  "./hooks/*": "./src/hooks/*.ts",
61
63
  "./*": "./src/*.ts",
@@ -601,6 +601,101 @@ describe('guardApiProxyRequest', () => {
601
601
  );
602
602
  });
603
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
+
604
699
  it('keeps users mutations on the default mutation bucket', async () => {
605
700
  vi.stubEnv('NODE_ENV', 'production');
606
701
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -673,6 +768,110 @@ describe('guardApiProxyRequest', () => {
673
768
  );
674
769
  });
675
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
+
676
875
  it('keeps unauthenticated cron reads on a strict proxy bucket', async () => {
677
876
  vi.stubEnv('NODE_ENV', 'production');
678
877
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -852,6 +1051,210 @@ describe('guardApiProxyRequest', () => {
852
1051
  expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe('anonymous');
853
1052
  });
854
1053
 
1054
+ it('keys verified users/me reads by session without applying a trust uplift', async () => {
1055
+ vi.stubEnv('NODE_ENV', 'production');
1056
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1057
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1058
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1059
+ mocks.redis.mockReturnValue({});
1060
+ mocks.extractIp.mockReturnValue('1.2.3.4');
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
+ });
1070
+ mocks.limit.mockResolvedValueOnce({
1071
+ success: false,
1072
+ limit: 600,
1073
+ remaining: 0,
1074
+ reset: Date.now() + 15_000,
1075
+ });
1076
+
1077
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1078
+ await import('../api-proxy-guard.js');
1079
+ clearApiProxyGuardLimiterCache();
1080
+
1081
+ const response = await guardApiProxyRequest(
1082
+ makeRequest('/api/v1/users/me/profile', 'GET', {
1083
+ cookie:
1084
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1085
+ }),
1086
+ { prefixBase: 'proxy:test:api' }
1087
+ );
1088
+
1089
+ expect(response?.status).toBe(429);
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);
1107
+ });
1108
+
1109
+ it('uses the authenticated workspace read policy for verified dashboard reads', async () => {
1110
+ vi.stubEnv('NODE_ENV', 'production');
1111
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1112
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1113
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1114
+ mocks.redis.mockReturnValue({});
1115
+ mocks.extractIp.mockReturnValue('1.2.3.4');
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
+ });
1125
+ mocks.limit.mockResolvedValueOnce({
1126
+ success: false,
1127
+ limit: 600,
1128
+ remaining: 0,
1129
+ reset: Date.now() + 15_000,
1130
+ });
1131
+
1132
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1133
+ await import('../api-proxy-guard.js');
1134
+ clearApiProxyGuardLimiterCache();
1135
+
1136
+ const response = await guardApiProxyRequest(
1137
+ makeRequest('/api/v1/workspaces/ws-1/dashboard/bootstrap', 'GET', {
1138
+ cookie:
1139
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1140
+ }),
1141
+ { prefixBase: 'proxy:test:api' }
1142
+ );
1143
+
1144
+ expect(response?.status).toBe(429);
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);
1156
+ });
1157
+
1158
+ it('uses authenticated default read limits for verified sessions on generic reads', async () => {
1159
+ vi.stubEnv('NODE_ENV', 'production');
1160
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1161
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1162
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1163
+ mocks.redis.mockReturnValue({});
1164
+ mocks.extractIp.mockReturnValue('1.2.3.4');
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
+ });
1174
+ mocks.limit.mockResolvedValueOnce({
1175
+ success: false,
1176
+ limit: 600,
1177
+ remaining: 0,
1178
+ reset: Date.now() + 15_000,
1179
+ });
1180
+
1181
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1182
+ await import('../api-proxy-guard.js');
1183
+ clearApiProxyGuardLimiterCache();
1184
+
1185
+ const response = await guardApiProxyRequest(
1186
+ makeRequest('/api/v1/unknown/read-only-dashboard-fragment', 'GET', {
1187
+ cookie:
1188
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1189
+ }),
1190
+ { prefixBase: 'proxy:test:api' }
1191
+ );
1192
+
1193
+ expect(response?.status).toBe(429);
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);
1207
+ });
1208
+
1209
+ it('covers legacy workspace reads with the verified workspace dashboard policy', async () => {
1210
+ vi.stubEnv('NODE_ENV', 'production');
1211
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1212
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1213
+ vi.stubEnv('NEXT_PUBLIC_SUPABASE_URL', '');
1214
+ mocks.redis.mockReturnValue({});
1215
+ mocks.extractIp.mockReturnValue('1.2.3.4');
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
+ });
1225
+ mocks.limit.mockResolvedValueOnce({
1226
+ success: false,
1227
+ limit: 600,
1228
+ remaining: 0,
1229
+ reset: Date.now() + 15_000,
1230
+ });
1231
+
1232
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1233
+ await import('../api-proxy-guard.js');
1234
+ clearApiProxyGuardLimiterCache();
1235
+
1236
+ const response = await guardApiProxyRequest(
1237
+ makeRequest('/api/workspaces/ws-1/settings', 'GET', {
1238
+ cookie:
1239
+ 'sb-resolved-kingfish-21146-auth-token.0=base64-validvalue; theme=dark',
1240
+ }),
1241
+ { prefixBase: 'proxy:test:api' }
1242
+ );
1243
+
1244
+ expect(response?.status).toBe(429);
1245
+ expect(response?.headers.get('X-RateLimit-Caller-Class')).toBe(
1246
+ 'authenticated'
1247
+ );
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);
1256
+ });
1257
+
855
1258
  it('rate limits API client-looking bearer reads until route auth validates them', async () => {
856
1259
  vi.stubEnv('NODE_ENV', 'production');
857
1260
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -1603,15 +2006,73 @@ describe('guardApiProxyRequest', () => {
1603
2006
  limit: 1800,
1604
2007
  window: '1 m',
1605
2008
  });
1606
- // Trusted traffic gets a tier-suffixed bucket.
2009
+ // Trusted traffic gets an authenticated, tier-suffixed bucket.
1607
2010
  expect(mocks.ratelimitPrefixes).toContain(
1608
- 'proxy:test:api:task-board-read:anonymous:t3:get:minute'
2011
+ 'proxy:test:api:task-board-read:authenticated:t3:get:minute'
1609
2012
  );
1610
2013
  // The limiter is keyed by the per-session subject key, not the shared IP.
1611
2014
  const limiterId = mocks.limit.mock.calls[0]?.[0] as string;
1612
2015
  expect(limiterId.startsWith('session:')).toBe(true);
1613
2016
  });
1614
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
+
1615
2076
  it('uplifts a trusted location per-IP without per-session keying', async () => {
1616
2077
  vi.stubEnv('NODE_ENV', 'production');
1617
2078
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -1656,6 +2117,56 @@ describe('guardApiProxyRequest', () => {
1656
2117
  expect(mocks.limit).toHaveBeenCalledWith('ip:1.2.3.4');
1657
2118
  });
1658
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
+
1659
2170
  it('keeps a trusted location on per-IP keying even with an untrusted session cookie', async () => {
1660
2171
  vi.stubEnv('NODE_ENV', 'production');
1661
2172
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -264,6 +264,7 @@ describe('Portless app origin registry', () => {
264
264
  'https://finance.tuturuuu.localhost',
265
265
  'https://hive.tuturuuu.localhost',
266
266
  'https://realtime.hive.tuturuuu.localhost',
267
+ 'https://infra.tuturuuu.localhost',
267
268
  'https://inventory.tuturuuu.localhost',
268
269
  'https://learn.tuturuuu.localhost',
269
270
  'https://mail.tuturuuu.localhost',
@@ -276,6 +277,7 @@ describe('Portless app origin registry', () => {
276
277
  'https://rewise.tuturuuu.localhost',
277
278
  'https://shortener.tuturuuu.localhost',
278
279
  'https://storefront.tuturuuu.localhost',
280
+ 'https://tanstack.tuturuuu.localhost',
279
281
  'https://tasks.tuturuuu.localhost',
280
282
  'https://teach.tuturuuu.localhost',
281
283
  'https://track.tuturuuu.localhost',