@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 +14 -0
- package/biome.json +1 -1
- package/package.json +17 -15
- package/src/__tests__/api-proxy-guard.test.ts +513 -2
- package/src/__tests__/app-url.test.ts +2 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +10 -45
- package/src/abuse-protection/__tests__/edge-trust.test.ts +58 -0
- package/src/abuse-protection/__tests__/index.test.ts +136 -0
- package/src/abuse-protection/backend-rate-limit.ts +5 -4
- package/src/abuse-protection/edge-trust.ts +74 -6
- package/src/abuse-protection/index.ts +73 -40
- package/src/api-proxy-guard.ts +131 -33
- package/src/currencies.test.ts +33 -0
- package/src/currencies.ts +28 -5
- package/src/effect.test.ts +247 -0
- package/src/effect.ts +259 -0
- package/src/internal-domains.ts +24 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/portless.ts +2 -0
- package/src/task-helper/task-operations.ts +3 -0
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "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": "
|
|
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.
|
|
23
|
-
"@tiptap/core": "3.
|
|
24
|
-
"@tiptap/react": "3.
|
|
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.
|
|
28
|
-
"@tuturuuu/supabase": "0.
|
|
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.
|
|
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.
|
|
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.
|
|
48
|
+
"@tuturuuu/types": "0.11.0",
|
|
48
49
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
49
50
|
"@types/diff": "^8.0.0",
|
|
50
|
-
"@types/node": "^
|
|
51
|
+
"@types/node": "^26.0.0",
|
|
51
52
|
"@types/react": "^19.2.17",
|
|
52
|
-
"@vitejs/plugin-react": "^6.0.
|
|
53
|
-
"typescript": "
|
|
54
|
-
"vite": "^8.0
|
|
55
|
-
"vitest": "^4.1.
|
|
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
|
|
2009
|
+
// Trusted traffic gets an authenticated, tier-suffixed bucket.
|
|
1607
2010
|
expect(mocks.ratelimitPrefixes).toContain(
|
|
1608
|
-
'proxy:test:api:task-board-read:
|
|
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',
|