@tuturuuu/utils 0.33.2 → 0.34.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 +46 -0
- package/package.json +5 -4
- package/src/__tests__/api-proxy-bearer-trust.test.ts +3 -0
- package/src/__tests__/api-proxy-guard.test.ts +6 -13
- package/src/__tests__/api-proxy-offline-response.test.ts +48 -0
- package/src/__tests__/api-proxy-test-request.ts +19 -0
- package/src/__tests__/daily-operation-download-isolation.test.ts +137 -0
- package/src/__tests__/offline-download-guard.test.ts +340 -0
- package/src/__tests__/username-policy.test.ts +70 -0
- package/src/account-benefits-server.ts +43 -0
- package/src/account-benefits.ts +45 -0
- package/src/api-proxy-guard.ts +8 -42
- package/src/api-proxy-read-policy.ts +43 -0
- package/src/common/metadata.test.ts +13 -1
- package/src/constants.ts +3 -0
- package/src/email/client.ts +1 -5
- package/src/email/reviewer-domain.d.mts +1 -0
- package/src/email/reviewer-domain.mjs +6 -0
- package/src/google-calendar-import-fence.test.ts +182 -0
- package/src/google-calendar-import-fence.ts +166 -0
- package/src/offline-download-guard.ts +160 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/playground-schema.test.ts +119 -0
- package/src/playground-schema.ts +173 -0
- package/src/playground-templates.ts +65 -0
- package/src/release-note-policy-core.cjs +28 -0
- package/src/release-note-policy-core.d.cts +5 -0
- package/src/release-note-policy.json +9 -0
- package/src/release-note-policy.ts +4 -0
- package/src/username-policy.json +87 -0
- package/src/username-policy.ts +18 -0
- package/vitest.config.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.34.0](https://github.com/tutur3u/platform/compare/utils-v0.33.3...utils-v0.34.0) (2026-10-04)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **calendar:** support full Google color choices and scoped edits ([#5661](https://github.com/tutur3u/platform/issues/5661)) ([8cafac5](https://github.com/tutur3u/platform/commit/8cafac53438b504eafa8bcc8213d18448e4a17bc))
|
|
9
|
+
* **identity:** enforce creator identity and media policies ([1078119](https://github.com/tutur3u/platform/commit/1078119eb4871568f76e4bc31005ba9f1b1ddd76)) ([e71e94f](https://github.com/tutur3u/platform/commit/e71e94fd1a643c5ee76979ba9444976ad4798906))
|
|
10
|
+
* **identity:** protect creator usernames and profile media ([#5740](https://github.com/tutur3u/platform/issues/5740)) ([f8e18a4](https://github.com/tutur3u/platform/commit/f8e18a4c39d5bb65acfc5a680ccb9b2557ae4bed))
|
|
11
|
+
* **mobile:** support offline data, cached images, and safe inventory checkout ([a45d634](https://github.com/tutur3u/platform/commit/a45d63498cc1e686d1d070f5861cf938c4e9a9ef)) ([#5734](https://github.com/tutur3u/platform/issues/5734)) ([2f0f397](https://github.com/tutur3u/platform/commit/2f0f39763daea56f1029fcc3e99e0be53c60e106))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* **calendar:** fence metadata reads and retain recurrence during imports ([3895d28](https://github.com/tutur3u/platform/commit/3895d28fb4acd0164f6f021ad15df4c39a83938e))
|
|
17
|
+
* **identity:** align live support validation and profile parity ([7d5e1b0](https://github.com/tutur3u/platform/commit/7d5e1b056d34d316177c21b178f201edf7680651))
|
|
18
|
+
* **identity:** close profile policy and upload bypasses ([6f5aae5](https://github.com/tutur3u/platform/commit/6f5aae52c2ba3e633b4f24dd3f683d4b564a952f))
|
|
19
|
+
* **identity:** enforce verified upload budgets and canonical profile validation ([8138fb5](https://github.com/tutur3u/platform/commit/8138fb59276191508416031f69195de291b2b06a))
|
|
20
|
+
* **identity:** integrate main and order unapplied creator migrations ([5512e24](https://github.com/tutur3u/platform/commit/5512e2414abaa0c78b68cbdd3186a1d4bab861e7))
|
|
21
|
+
* **identity:** preserve budgeted avatar tickets and atomic handle claims ([b18092f](https://github.com/tutur3u/platform/commit/b18092fb7bf8632f6ba5f99ab8a61308d09544b9))
|
|
22
|
+
* **mobile:** harden offline inventory and scoped cache replay ([d9c0b13](https://github.com/tutur3u/platform/commit/d9c0b13b386287fb874be62a8b8ec794deeb6407))
|
|
23
|
+
* **release:** gate critical app promotion on staged API probes ([f39cea3](https://github.com/tutur3u/platform/commit/f39cea3621b504a9320b6d79675aa42b905b0476)) ([#5761](https://github.com/tutur3u/platform/issues/5761)) ([391397f](https://github.com/tutur3u/platform/commit/391397f90f651d041d8f0e9ae566f8c3eab60dd7))
|
|
24
|
+
* **release:** preserve manual test notes and share filtering policy ([ff312ca](https://github.com/tutur3u/platform/commit/ff312ca8b16e36bdc7f65484cded768de3073c17))
|
|
25
|
+
* **releases:** omit merge bookkeeping from release summaries ([c66e0b1](https://github.com/tutur3u/platform/commit/c66e0b139c89706db32ec70c3e87fcc9c4018b6c)) ([#5741](https://github.com/tutur3u/platform/issues/5741)) ([552cd5a](https://github.com/tutur3u/platform/commit/552cd5ae85d19d9147f1367475f5f6b3bf522a63))
|
|
26
|
+
* **sdk:** bound mixed judge jobs and validate playground exports ([c5efff9](https://github.com/tutur3u/platform/commit/c5efff9427dc36cae6c4212871a335f9cd4bde03))
|
|
27
|
+
* **security:** isolate bulk limits from ordinary web operations ([b3a9add](https://github.com/tutur3u/platform/commit/b3a9addf90271c7e28319a2138bbd52aaa294bc1))
|
|
28
|
+
* **security:** isolate offline bulk protection from daily web operations ([#5752](https://github.com/tutur3u/platform/issues/5752)) ([fd32aca](https://github.com/tutur3u/platform/commit/fd32aca3319c67d82149975e8c3a7615dfbca275))
|
|
29
|
+
* **security:** stage offline download protection activation ([018f6ab](https://github.com/tutur3u/platform/commit/018f6ab2ada3fef843d66789dee6ec45fba905d3))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Performance Improvements
|
|
33
|
+
|
|
34
|
+
* **devboxes:** parallelize bounded execution and retain warm playgrounds ([#5746](https://github.com/tutur3u/platform/issues/5746)) ([efd8ac4](https://github.com/tutur3u/platform/commit/efd8ac44b5c9f8cbf2ab5d64fbe89e1f7afe4f96))
|
|
35
|
+
* **devboxes:** parallelize bounded runner execution and retain warm playgrounds ([c31cbf3](https://github.com/tutur3u/platform/commit/c31cbf3a2a7cb5214d75183896791366affdc7e6))
|
|
36
|
+
|
|
37
|
+
## [0.33.3](https://github.com/tutur3u/platform/compare/utils-v0.33.2...utils-v0.33.3) (2026-10-02)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
### Bug Fixes
|
|
41
|
+
|
|
42
|
+
* **calendar:** preserve opaque rendering cues and hydrate source colors ([33847dd](https://github.com/tutur3u/platform/commit/33847ddcd8a3cf2b5cef8ebf50ca4c1700424a27))
|
|
43
|
+
* **calendar:** render opaque readable event colors ([#5694](https://github.com/tutur3u/platform/issues/5694)) ([6e3d81d](https://github.com/tutur3u/platform/commit/6e3d81d6fdd6f8155f12e36ede58d31fde0ac362))
|
|
44
|
+
* **mobile:** harden timezone retry recovery ([264b4b8](https://github.com/tutur3u/platform/commit/264b4b89ab9e1cb4194374a3714b46832c5e486a))
|
|
45
|
+
* **mobile:** resolve timezone settings with verified native sessions ([#5720](https://github.com/tutur3u/platform/issues/5720)) ([5640ca2](https://github.com/tutur3u/platform/commit/5640ca22bcbfda6cd0470f39171ffa2366af779e))
|
|
46
|
+
* **mobile:** restore verified timezone resolution ([462c68b](https://github.com/tutur3u/platform/commit/462c68bf22b2b412dd1825c12c3558b10b028a40))
|
|
47
|
+
* **mobile:** retain timezone snapshots during overlapping loads ([eff42e2](https://github.com/tutur3u/platform/commit/eff42e2cf9d3159ee6fc68be1079df5f50fd92e2))
|
|
48
|
+
|
|
3
49
|
## [0.33.2](https://github.com/tutur3u/platform/compare/utils-v0.33.1...utils-v0.33.2) (2026-10-01)
|
|
4
50
|
|
|
5
51
|
|
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.34.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
"@tiptap/react": "3.31.3",
|
|
25
25
|
"@tuturuuu/google": "0.1.1",
|
|
26
26
|
"@tuturuuu/icons": "0.1.1",
|
|
27
|
-
"@tuturuuu/internal-api": "0.
|
|
27
|
+
"@tuturuuu/internal-api": "0.49.0",
|
|
28
28
|
"@tuturuuu/supabase": "0.5.2",
|
|
29
|
+
"@tuturuuu/turnstile": "0.1.3",
|
|
29
30
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
31
|
"@upstash/redis": "^1.38.4",
|
|
31
32
|
"cn": "^0.3.0",
|
|
@@ -41,11 +42,11 @@
|
|
|
41
42
|
"server-only": "^0.0.1",
|
|
42
43
|
"uuid": "^14.0.2",
|
|
43
44
|
"y-prosemirror": "^1.3.7",
|
|
44
|
-
"yjs": "^13.6.
|
|
45
|
+
"yjs": "^13.6.33",
|
|
45
46
|
"zod": "^4.6.2"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
|
-
"@tuturuuu/types": "0.
|
|
49
|
+
"@tuturuuu/types": "0.34.0",
|
|
49
50
|
"@tuturuuu/typescript-config": "0.1.2",
|
|
50
51
|
"@types/diff": "^8.0.0",
|
|
51
52
|
"@types/node": "^26.5.1",
|
|
@@ -7,6 +7,9 @@ const mocks = vi.hoisted(() => ({
|
|
|
7
7
|
blocked: vi.fn(),
|
|
8
8
|
prefixes: [] as string[],
|
|
9
9
|
}));
|
|
10
|
+
vi.mock('../offline-download-guard', () => ({
|
|
11
|
+
guardOfflineDownloadRequest: async () => null,
|
|
12
|
+
}));
|
|
10
13
|
vi.mock('../required-mfa-runtime', () => ({
|
|
11
14
|
enforceRequiredMfaRequest: async () => null,
|
|
12
15
|
}));
|
|
@@ -3,6 +3,12 @@ vi.mock('../required-mfa-runtime', () => ({
|
|
|
3
3
|
}));
|
|
4
4
|
|
|
5
5
|
import { NextRequest } from 'next/server';
|
|
6
|
+
import { makeRequest } from './api-proxy-test-request';
|
|
7
|
+
|
|
8
|
+
vi.mock('../offline-download-guard', () => ({
|
|
9
|
+
guardOfflineDownloadRequest: async () => null,
|
|
10
|
+
}));
|
|
11
|
+
|
|
6
12
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
13
|
|
|
8
14
|
const mocks = vi.hoisted(() => ({
|
|
@@ -74,19 +80,6 @@ vi.mock('../request-emoji-limit', () => ({
|
|
|
74
80
|
) => mocks.validateEmoji(req, options),
|
|
75
81
|
}));
|
|
76
82
|
|
|
77
|
-
function makeRequest(
|
|
78
|
-
pathname = '/api/test',
|
|
79
|
-
method = 'POST',
|
|
80
|
-
headers?: Record<string, string>,
|
|
81
|
-
body = '{}'
|
|
82
|
-
) {
|
|
83
|
-
return new NextRequest(`http://localhost${pathname}`, {
|
|
84
|
-
method,
|
|
85
|
-
headers,
|
|
86
|
-
body: method === 'GET' || method === 'HEAD' ? undefined : body,
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
|
|
90
83
|
function singleReadRoutePolicies() {
|
|
91
84
|
return [
|
|
92
85
|
{
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const guard = vi.hoisted(() => vi.fn());
|
|
5
|
+
vi.mock('../required-mfa-runtime', () => ({
|
|
6
|
+
enforceRequiredMfaRequest: async () => null,
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('../offline-download-guard', () => ({
|
|
9
|
+
guardOfflineDownloadRequest: guard,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
import { guardApiProxyRequest } from '../api-proxy-guard';
|
|
13
|
+
import { isFinanceInvoiceCreateSupportRead } from '../api-proxy-read-policy';
|
|
14
|
+
|
|
15
|
+
describe('offline guard proxy integration', () => {
|
|
16
|
+
it('propagates a download challenge without continuing the request', async () => {
|
|
17
|
+
const challenge = Response.json(
|
|
18
|
+
{ message: 'Verify download' },
|
|
19
|
+
{ status: 403 }
|
|
20
|
+
);
|
|
21
|
+
guard.mockResolvedValueOnce(challenge);
|
|
22
|
+
const request = new NextRequest(
|
|
23
|
+
'https://example.test/api/v1/workspaces/ws/tasks'
|
|
24
|
+
);
|
|
25
|
+
expect(
|
|
26
|
+
await guardApiProxyRequest(request, { prefixBase: 'test:offline' })
|
|
27
|
+
).toBe(challenge);
|
|
28
|
+
expect(guard).toHaveBeenCalledWith(request);
|
|
29
|
+
});
|
|
30
|
+
it.each(['permissions', 'members'])(
|
|
31
|
+
'excludes static settings/%s from invoice reads',
|
|
32
|
+
(name) => {
|
|
33
|
+
const request = new NextRequest(
|
|
34
|
+
`https://example.test/api/v1/workspaces/ws/settings/${name}`
|
|
35
|
+
);
|
|
36
|
+
expect(isFinanceInvoiceCreateSupportRead(request)).toBe(false);
|
|
37
|
+
}
|
|
38
|
+
);
|
|
39
|
+
it.each(['configs', 'custom-config'])(
|
|
40
|
+
'retains supported settings/%s invoice reads',
|
|
41
|
+
(name) => {
|
|
42
|
+
const request = new NextRequest(
|
|
43
|
+
`https://example.test/api/v1/workspaces/ws/settings/${name}`
|
|
44
|
+
);
|
|
45
|
+
expect(isFinanceInvoiceCreateSupportRead(request)).toBe(true);
|
|
46
|
+
}
|
|
47
|
+
);
|
|
48
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server';
|
|
2
|
+
|
|
3
|
+
export function makeRequest(
|
|
4
|
+
pathname = '/api/test',
|
|
5
|
+
method = 'POST',
|
|
6
|
+
headers?: Record<string, string>,
|
|
7
|
+
body = '{}'
|
|
8
|
+
) {
|
|
9
|
+
return new NextRequest(`http://localhost${pathname}`, {
|
|
10
|
+
method,
|
|
11
|
+
headers: {
|
|
12
|
+
...(method !== 'GET' && method !== 'HEAD'
|
|
13
|
+
? { 'content-type': 'application/json' }
|
|
14
|
+
: {}),
|
|
15
|
+
...headers,
|
|
16
|
+
},
|
|
17
|
+
body: method === 'GET' || method === 'HEAD' ? undefined : body,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
const mocks = vi.hoisted(() => ({
|
|
5
|
+
rest: vi.fn(),
|
|
6
|
+
limiter: vi.fn(),
|
|
7
|
+
limit: vi.fn(),
|
|
8
|
+
verify: vi.fn(),
|
|
9
|
+
mfa: vi.fn(),
|
|
10
|
+
block: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('../required-mfa-runtime', () => ({
|
|
13
|
+
enforceRequiredMfaRequest: mocks.mfa,
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../abuse-protection/edge', () => ({
|
|
16
|
+
extractIPFromRequest: () => '192.0.2.1',
|
|
17
|
+
isIPBlockedEdge: mocks.block,
|
|
18
|
+
readEdgeAbuseProtectionControls: async () => ({
|
|
19
|
+
ipBlockingEnabled: true,
|
|
20
|
+
rateLimitsEnabled: false,
|
|
21
|
+
}),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock('../abuse-protection/edge-trust', () => ({
|
|
24
|
+
getCachedTrustEntries: async () => new Map(),
|
|
25
|
+
hasCachedIpBlockAppealRelief: async () => false,
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('../upstash-rest', () => ({
|
|
28
|
+
getUpstashRestRedisClient: mocks.rest,
|
|
29
|
+
getUpstashRatelimitRedisClient: mocks.limiter,
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('@tuturuuu/turnstile/server', () => ({
|
|
32
|
+
verifyTurnstileToken: mocks.verify,
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('@upstash/ratelimit', () => ({
|
|
35
|
+
Ratelimit: class {
|
|
36
|
+
static slidingWindow() {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
limit(key: string) {
|
|
40
|
+
return mocks.limit(key);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
vi.mock('../request-emoji-limit', () => ({
|
|
45
|
+
validateRequestEmojiLimit: async () => null,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
let guardApiProxyRequest: typeof import('../api-proxy-guard')['guardApiProxyRequest'];
|
|
49
|
+
|
|
50
|
+
import { OFFLINE_DOWNLOAD_HEADER } from '../offline-download-guard';
|
|
51
|
+
|
|
52
|
+
const paths = [
|
|
53
|
+
['finance', '/api/workspaces/ws/wallets/infinite'],
|
|
54
|
+
['inventory', '/api/v1/workspaces/ws/inventory/products'],
|
|
55
|
+
['contacts', '/api/v1/workspaces/ws/users/database'],
|
|
56
|
+
['cms', '/api/v1/workspaces/ws/external-projects'],
|
|
57
|
+
['contacts', '/api/v1/workspaces/ws/members'],
|
|
58
|
+
['cms', '/api/v1/workspaces/ws/members'],
|
|
59
|
+
['tasks', '/api/v1/workspaces/ws/tasks'],
|
|
60
|
+
] as const;
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
vi.clearAllMocks();
|
|
63
|
+
vi.resetModules();
|
|
64
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
65
|
+
({ guardApiProxyRequest } = await import('../api-proxy-guard.js'));
|
|
66
|
+
mocks.mfa.mockResolvedValue(null);
|
|
67
|
+
mocks.block.mockResolvedValue(null);
|
|
68
|
+
mocks.rest.mockRejectedValue(new Error('offline Redis unavailable'));
|
|
69
|
+
mocks.limiter.mockResolvedValue(null);
|
|
70
|
+
mocks.limit.mockResolvedValue({ success: false, reset: Date.now() + 1000 });
|
|
71
|
+
mocks.verify.mockRejectedValue(new Error('Turnstile unavailable'));
|
|
72
|
+
});
|
|
73
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
74
|
+
it.each(paths)(
|
|
75
|
+
'keeps %s ordinary reads independent of optional providers through the real proxy',
|
|
76
|
+
async (app, path) => {
|
|
77
|
+
for (const enabled of ['', 'false', 'true']) {
|
|
78
|
+
vi.stubEnv('OFFLINE_DOWNLOAD_PROTECTION_ENABLED', enabled);
|
|
79
|
+
for (const method of ['GET', 'HEAD']) {
|
|
80
|
+
for (const headers of [
|
|
81
|
+
new Headers(),
|
|
82
|
+
new Headers({ authorization: 'Bearer synthetic-session' }),
|
|
83
|
+
new Headers({ cookie: 'tuturuuu_app_session=ttr_app_synthetic' }),
|
|
84
|
+
]) {
|
|
85
|
+
const request = new NextRequest(
|
|
86
|
+
`https://${app}.tuturuuu.com${path}`,
|
|
87
|
+
{
|
|
88
|
+
method,
|
|
89
|
+
headers,
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
expect(
|
|
93
|
+
await guardApiProxyRequest(request, {
|
|
94
|
+
prefixBase: `proxy:${app}:api`,
|
|
95
|
+
})
|
|
96
|
+
).toBeNull();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
expect(mocks.rest).not.toHaveBeenCalled();
|
|
101
|
+
expect(mocks.limiter).not.toHaveBeenCalled();
|
|
102
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
103
|
+
expect(mocks.verify).not.toHaveBeenCalled();
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
it('keeps explicit bulk downloads unavailable when the optional provider fails', async () => {
|
|
107
|
+
vi.stubEnv('OFFLINE_DOWNLOAD_PROTECTION_ENABLED', 'true');
|
|
108
|
+
const request = new NextRequest(
|
|
109
|
+
'https://inventory.tuturuuu.com/api/v1/workspaces/ws/inventory/products',
|
|
110
|
+
{ headers: { [OFFLINE_DOWNLOAD_HEADER]: '1' } }
|
|
111
|
+
);
|
|
112
|
+
const result = await guardApiProxyRequest(request, {
|
|
113
|
+
prefixBase: 'proxy:inventory:api',
|
|
114
|
+
});
|
|
115
|
+
expect(result?.status).toBe(503);
|
|
116
|
+
expect(result?.headers.get('Retry-After')).toBe('30');
|
|
117
|
+
});
|
|
118
|
+
it('keeps baseline required MFA and IP blocks effective during offline-provider outages', async () => {
|
|
119
|
+
vi.stubEnv('OFFLINE_DOWNLOAD_PROTECTION_ENABLED', 'true');
|
|
120
|
+
const request = new NextRequest(
|
|
121
|
+
'https://finance.tuturuuu.com/api/workspaces/ws/wallets/infinite'
|
|
122
|
+
);
|
|
123
|
+
mocks.mfa.mockResolvedValue(
|
|
124
|
+
NextResponse.json({ error: 'Required MFA' }, { status: 403 })
|
|
125
|
+
);
|
|
126
|
+
expect(
|
|
127
|
+
(await guardApiProxyRequest(request, { prefixBase: 'proxy:finance:api' }))
|
|
128
|
+
?.status
|
|
129
|
+
).toBe(403);
|
|
130
|
+
mocks.mfa.mockResolvedValue(null);
|
|
131
|
+
mocks.block.mockResolvedValue({ expiresAt: new Date(Date.now() + 30000) });
|
|
132
|
+
expect(
|
|
133
|
+
(await guardApiProxyRequest(request, { prefixBase: 'proxy:finance:api' }))
|
|
134
|
+
?.status
|
|
135
|
+
).toBe(429);
|
|
136
|
+
expect(mocks.rest).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { NextRequest } from 'next/server';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const mocks = vi.hoisted(() => ({
|
|
6
|
+
limit: vi.fn(),
|
|
7
|
+
verify: vi.fn(),
|
|
8
|
+
trust: vi.fn(),
|
|
9
|
+
get: vi.fn(),
|
|
10
|
+
set: vi.fn(),
|
|
11
|
+
redis: vi.fn(),
|
|
12
|
+
limiterRedis: vi.fn(),
|
|
13
|
+
ip: vi.fn(() => '192.0.2.1'),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('@upstash/ratelimit', () => ({
|
|
16
|
+
Ratelimit: class {
|
|
17
|
+
static slidingWindow() {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
limit(key: string) {
|
|
21
|
+
return mocks.limit(key);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('@tuturuuu/turnstile/server', () => ({
|
|
26
|
+
verifyTurnstileToken: mocks.verify,
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('../abuse-protection/edge', () => ({
|
|
29
|
+
extractIPFromRequest: mocks.ip,
|
|
30
|
+
}));
|
|
31
|
+
vi.mock('../abuse-protection/edge-trust', () => ({
|
|
32
|
+
getCachedTrustEntries: mocks.trust,
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('../upstash-rest', () => ({
|
|
35
|
+
getUpstashRestRedisClient: mocks.redis,
|
|
36
|
+
getUpstashRatelimitRedisClient: mocks.limiterRedis,
|
|
37
|
+
}));
|
|
38
|
+
vi.mock('../api-proxy-guard', () => ({
|
|
39
|
+
buildProxySessionSubjectKey: (name: string, value: string) =>
|
|
40
|
+
`session:${createHash('sha256').update(`${name}:${value}`).digest('hex').slice(0, 24)}`,
|
|
41
|
+
getProxySessionSubjectKeyFromCookieHeader: async () => null,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
import {
|
|
45
|
+
guardOfflineDownloadRequest,
|
|
46
|
+
isOfflineCapableRead,
|
|
47
|
+
OFFLINE_DOWNLOAD_HEADER,
|
|
48
|
+
} from '../offline-download-guard';
|
|
49
|
+
|
|
50
|
+
const path = '/api/v1/workspaces/workspace/inventory/products';
|
|
51
|
+
function request({
|
|
52
|
+
bulk = false,
|
|
53
|
+
token,
|
|
54
|
+
bearer = 'synthetic-session',
|
|
55
|
+
pathname = path,
|
|
56
|
+
method = 'GET',
|
|
57
|
+
}: {
|
|
58
|
+
bulk?: boolean;
|
|
59
|
+
token?: string;
|
|
60
|
+
bearer?: string | null;
|
|
61
|
+
pathname?: string;
|
|
62
|
+
method?: string;
|
|
63
|
+
} = {}) {
|
|
64
|
+
return new NextRequest(`https://example.test${pathname}`, {
|
|
65
|
+
method,
|
|
66
|
+
headers: {
|
|
67
|
+
...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
|
|
68
|
+
...(bulk ? { [OFFLINE_DOWNLOAD_HEADER]: '1' } : {}),
|
|
69
|
+
...(token ? { 'x-tuturuuu-turnstile-token': token } : {}),
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('offline download guards', () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
77
|
+
vi.stubEnv('OFFLINE_DOWNLOAD_PROTECTION_ENABLED', 'true');
|
|
78
|
+
vi.clearAllMocks();
|
|
79
|
+
mocks.redis.mockResolvedValue({ get: mocks.get, set: mocks.set });
|
|
80
|
+
mocks.limiterRedis.mockResolvedValue({});
|
|
81
|
+
mocks.limit.mockResolvedValue({ success: true, reset: Date.now() + 1000 });
|
|
82
|
+
mocks.verify.mockResolvedValue(undefined);
|
|
83
|
+
mocks.trust.mockResolvedValue(new Map());
|
|
84
|
+
mocks.get.mockResolvedValue(null);
|
|
85
|
+
mocks.set.mockResolvedValue('OK');
|
|
86
|
+
});
|
|
87
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
88
|
+
|
|
89
|
+
it.each([
|
|
90
|
+
'/api/v1/workspaces/ws/wallets',
|
|
91
|
+
'/api/v1/workspaces/ws/inventory/products',
|
|
92
|
+
'/api/v1/workspaces/ws/members',
|
|
93
|
+
'/api/v1/workspaces/ws/task-boards/board/lists',
|
|
94
|
+
])(
|
|
95
|
+
'leaves daily operations outside activated offline protection: %s',
|
|
96
|
+
async (pathname) => {
|
|
97
|
+
mocks.limit.mockRejectedValue(new Error('quota service unavailable'));
|
|
98
|
+
for (const method of ['GET', 'HEAD']) {
|
|
99
|
+
const input = request({ pathname, method, token: 'captcha' });
|
|
100
|
+
expect(isOfflineCapableRead(input)).toBe(true);
|
|
101
|
+
expect(await guardOfflineDownloadRequest(input)).toBeNull();
|
|
102
|
+
}
|
|
103
|
+
expect(mocks.redis).not.toHaveBeenCalled();
|
|
104
|
+
expect(mocks.limiterRedis).not.toHaveBeenCalled();
|
|
105
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
106
|
+
expect(mocks.trust).not.toHaveBeenCalled();
|
|
107
|
+
expect(mocks.verify).not.toHaveBeenCalled();
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
it.each(['0', 'true', ''])(
|
|
112
|
+
'requires the explicit bulk marker value 1: %s',
|
|
113
|
+
async (marker) => {
|
|
114
|
+
const input = request();
|
|
115
|
+
input.headers.set(OFFLINE_DOWNLOAD_HEADER, marker);
|
|
116
|
+
expect(isOfflineCapableRead(input)).toBe(true);
|
|
117
|
+
expect(await guardOfflineDownloadRequest(input)).toBeNull();
|
|
118
|
+
expect(mocks.redis).not.toHaveBeenCalled();
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
it.each([
|
|
123
|
+
'/api/v1/workspaces/ws/wallets',
|
|
124
|
+
'/api/workspaces/ws/transactions/infinite',
|
|
125
|
+
'/api/v1/workspaces/ws/members',
|
|
126
|
+
'/api/v1/users/calendar-settings',
|
|
127
|
+
])(
|
|
128
|
+
'preserves ordinary reads before protection activation: %s',
|
|
129
|
+
async (pathname) => {
|
|
130
|
+
vi.stubEnv('OFFLINE_DOWNLOAD_PROTECTION_ENABLED', '');
|
|
131
|
+
mocks.redis.mockResolvedValue(null);
|
|
132
|
+
mocks.limiterRedis.mockResolvedValue(null);
|
|
133
|
+
for (const method of ['GET', 'HEAD']) {
|
|
134
|
+
const input = request({ pathname, method });
|
|
135
|
+
expect(isOfflineCapableRead(input)).toBe(true);
|
|
136
|
+
expect(await guardOfflineDownloadRequest(input)).toBeNull();
|
|
137
|
+
}
|
|
138
|
+
expect(mocks.redis).not.toHaveBeenCalled();
|
|
139
|
+
expect(mocks.limiterRedis).not.toHaveBeenCalled();
|
|
140
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
it.each(['', 'false', 'TRUE', '1'])(
|
|
144
|
+
'blocks marked bulk downloads until explicit activation: %s',
|
|
145
|
+
async (enabled) => {
|
|
146
|
+
vi.stubEnv('OFFLINE_DOWNLOAD_PROTECTION_ENABLED', enabled);
|
|
147
|
+
const response = await guardOfflineDownloadRequest(
|
|
148
|
+
request({ bulk: true, token: 'captcha' })
|
|
149
|
+
);
|
|
150
|
+
expect(response?.status).toBe(503);
|
|
151
|
+
expect(response?.headers.get('Cache-Control')).toBe('no-store');
|
|
152
|
+
expect(response?.headers.get('Retry-After')).toBe('30');
|
|
153
|
+
expect(mocks.redis).not.toHaveBeenCalled();
|
|
154
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
155
|
+
expect(mocks.verify).not.toHaveBeenCalled();
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
it('does not infer activation from available Redis or account trust', async () => {
|
|
159
|
+
vi.stubEnv('OFFLINE_DOWNLOAD_PROTECTION_ENABLED', 'false');
|
|
160
|
+
mocks.trust.mockImplementation(
|
|
161
|
+
async ([key]) => new Map([[key, { m: 2, verified: true }]])
|
|
162
|
+
);
|
|
163
|
+
expect(
|
|
164
|
+
(await guardOfflineDownloadRequest(request({ bulk: true })))?.status
|
|
165
|
+
).toBe(503);
|
|
166
|
+
expect(mocks.redis).not.toHaveBeenCalled();
|
|
167
|
+
expect(mocks.trust).not.toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
for (const pathname of [
|
|
171
|
+
path,
|
|
172
|
+
'/api/workspaces/ws/transactions/infinite',
|
|
173
|
+
'/api/v1/workspaces/ws/task-boards/board/lists',
|
|
174
|
+
'/api/v1/users/me/tasks',
|
|
175
|
+
'/api/v1/workspaces/ws/calendar/events',
|
|
176
|
+
'/api/v1/mobile-calendar/api/v1/workspaces/ws/calendar/events',
|
|
177
|
+
'/api/v1/mobile-calendar/api/v1/users/calendar-settings',
|
|
178
|
+
'/api/v1/workspaces/ws/products',
|
|
179
|
+
'/api/v1/workspaces/ws/habits',
|
|
180
|
+
]) {
|
|
181
|
+
it(`recognizes protected reads without requiring a client marker: ${pathname}`, () => {
|
|
182
|
+
expect(isOfflineCapableRead(request({ pathname }))).toBe(true);
|
|
183
|
+
expect(isOfflineCapableRead(request({ pathname, method: 'POST' }))).toBe(
|
|
184
|
+
false
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
it('leaves unrelated APIs outside the new guard', async () => {
|
|
189
|
+
expect(
|
|
190
|
+
await guardOfflineDownloadRequest(
|
|
191
|
+
request({ pathname: '/api/v1/auth/login', bulk: true })
|
|
192
|
+
)
|
|
193
|
+
).toBeNull();
|
|
194
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
it('enforces IP and session budgets for explicitly marked bulk reads', async () => {
|
|
197
|
+
expect(
|
|
198
|
+
await guardOfflineDownloadRequest(
|
|
199
|
+
request({ bulk: true, token: 'captcha' })
|
|
200
|
+
)
|
|
201
|
+
).toBeNull();
|
|
202
|
+
expect(mocks.limit.mock.calls.map(([key]) => key)).toEqual([
|
|
203
|
+
'ip:192.0.2.1',
|
|
204
|
+
'ip:192.0.2.1',
|
|
205
|
+
expect.stringMatching(/^session:/),
|
|
206
|
+
expect.stringMatching(/^session:/),
|
|
207
|
+
]);
|
|
208
|
+
expect(JSON.stringify(mocks.limit.mock.calls)).not.toContain(
|
|
209
|
+
'synthetic-session'
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
it('rate limits marked bulk reads before attempting CAPTCHA', async () => {
|
|
213
|
+
mocks.limit.mockResolvedValue({
|
|
214
|
+
success: false,
|
|
215
|
+
reset: Date.now() + 2000,
|
|
216
|
+
});
|
|
217
|
+
const response = await guardOfflineDownloadRequest(
|
|
218
|
+
request({ bulk: true, token: 'captcha' })
|
|
219
|
+
);
|
|
220
|
+
expect(response?.status).toBe(429);
|
|
221
|
+
expect(Number(response?.headers.get('Retry-After'))).toBeGreaterThan(0);
|
|
222
|
+
expect(mocks.verify).not.toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
it('issues an explicit challenge, not a permission denial', async () => {
|
|
225
|
+
const response = await guardOfflineDownloadRequest(request({ bulk: true }));
|
|
226
|
+
expect(response?.status).toBe(403);
|
|
227
|
+
expect(response?.headers.get('X-Abuse-Challenge')).toBe('turnstile');
|
|
228
|
+
expect(mocks.set).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
it('verifies once and shares session clearance across gateway and owner', async () => {
|
|
231
|
+
const storage = new Map<string, unknown>();
|
|
232
|
+
mocks.get.mockImplementation(async (key) => storage.get(key));
|
|
233
|
+
mocks.set.mockImplementation(async (key, value) => {
|
|
234
|
+
storage.set(key, value);
|
|
235
|
+
return 'OK';
|
|
236
|
+
});
|
|
237
|
+
expect(
|
|
238
|
+
await guardOfflineDownloadRequest(
|
|
239
|
+
request({
|
|
240
|
+
bulk: true,
|
|
241
|
+
token: 'one-use',
|
|
242
|
+
pathname: `/api/v1/mobile-calendar/api/v1/workspaces/ws/calendar/events`,
|
|
243
|
+
})
|
|
244
|
+
)
|
|
245
|
+
).toBeNull();
|
|
246
|
+
expect(
|
|
247
|
+
await guardOfflineDownloadRequest(
|
|
248
|
+
request({
|
|
249
|
+
bulk: true,
|
|
250
|
+
token: 'one-use',
|
|
251
|
+
pathname: '/api/v1/workspaces/ws/calendar/events',
|
|
252
|
+
})
|
|
253
|
+
)
|
|
254
|
+
).toBeNull();
|
|
255
|
+
expect(mocks.verify).toHaveBeenCalledTimes(1);
|
|
256
|
+
expect(mocks.set).toHaveBeenCalledWith(
|
|
257
|
+
expect.stringMatching(/^offline:clearance:session:/),
|
|
258
|
+
'passed',
|
|
259
|
+
{ ex: 900 }
|
|
260
|
+
);
|
|
261
|
+
expect(
|
|
262
|
+
await guardOfflineDownloadRequest(
|
|
263
|
+
request({ bulk: true, bearer: 'another-session' })
|
|
264
|
+
)?.then((value) => value?.status)
|
|
265
|
+
).toBe(403);
|
|
266
|
+
});
|
|
267
|
+
it('does not treat a presented header or neutral verified session as elevated trust', async () => {
|
|
268
|
+
mocks.trust.mockImplementation(
|
|
269
|
+
async ([key]) => new Map([[key, { m: 1, verified: true }]])
|
|
270
|
+
);
|
|
271
|
+
expect(
|
|
272
|
+
(await guardOfflineDownloadRequest(request({ bulk: true })))?.status
|
|
273
|
+
).toBe(403);
|
|
274
|
+
});
|
|
275
|
+
it('uses existing verified elevated trust without changing quotas', async () => {
|
|
276
|
+
mocks.trust.mockImplementation(
|
|
277
|
+
async ([key]) => new Map([[key, { m: 2, verified: true }]])
|
|
278
|
+
);
|
|
279
|
+
expect(
|
|
280
|
+
await guardOfflineDownloadRequest(request({ bulk: true }))
|
|
281
|
+
).toBeNull();
|
|
282
|
+
expect(mocks.limit).toHaveBeenCalledTimes(4);
|
|
283
|
+
expect(mocks.set).not.toHaveBeenCalled();
|
|
284
|
+
expect(mocks.verify).not.toHaveBeenCalled();
|
|
285
|
+
});
|
|
286
|
+
it('retains the challenge when Turnstile rejects the token', async () => {
|
|
287
|
+
mocks.verify.mockRejectedValue(new Error('invalid'));
|
|
288
|
+
expect(
|
|
289
|
+
(
|
|
290
|
+
await guardOfflineDownloadRequest(
|
|
291
|
+
request({ bulk: true, token: 'invalid' })
|
|
292
|
+
)
|
|
293
|
+
)?.status
|
|
294
|
+
).toBe(403);
|
|
295
|
+
expect(mocks.set).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
it('fails closed for marked bulk when distributed storage is missing', async () => {
|
|
298
|
+
mocks.redis.mockResolvedValue(null);
|
|
299
|
+
expect(
|
|
300
|
+
(await guardOfflineDownloadRequest(request({ bulk: true })))?.status
|
|
301
|
+
).toBe(503);
|
|
302
|
+
});
|
|
303
|
+
it('fails closed on limiter/store errors', async () => {
|
|
304
|
+
mocks.limit.mockRejectedValue(new Error('unavailable'));
|
|
305
|
+
expect(
|
|
306
|
+
(await guardOfflineDownloadRequest(request({ bulk: true })))?.status
|
|
307
|
+
).toBe(503);
|
|
308
|
+
});
|
|
309
|
+
it('keeps authenticated gateway sessions separate when the client IP is absent', async () => {
|
|
310
|
+
mocks.ip.mockReturnValueOnce('unknown');
|
|
311
|
+
expect(
|
|
312
|
+
await guardOfflineDownloadRequest(
|
|
313
|
+
request({ bulk: true, token: 'captcha' })
|
|
314
|
+
)
|
|
315
|
+
).toBeNull();
|
|
316
|
+
expect(mocks.limit).toHaveBeenCalledTimes(2);
|
|
317
|
+
expect(
|
|
318
|
+
mocks.limit.mock.calls.every(([key]) =>
|
|
319
|
+
String(key).startsWith('session:')
|
|
320
|
+
)
|
|
321
|
+
).toBe(true);
|
|
322
|
+
});
|
|
323
|
+
it('fails closed for an unknown anonymous address', async () => {
|
|
324
|
+
mocks.ip.mockReturnValueOnce('unknown');
|
|
325
|
+
expect(
|
|
326
|
+
(await guardOfflineDownloadRequest(request({ bearer: null, bulk: true })))
|
|
327
|
+
?.status
|
|
328
|
+
).toBe(503);
|
|
329
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
it('guards global exchange rates and accepts Bearer separator whitespace', async () => {
|
|
332
|
+
expect(
|
|
333
|
+
isOfflineCapableRead(request({ pathname: '/api/v1/exchange-rates' }))
|
|
334
|
+
).toBe(true);
|
|
335
|
+
const input = request({ bulk: true, token: 'captcha' });
|
|
336
|
+
input.headers.set('authorization', 'Bearer synthetic-session');
|
|
337
|
+
expect(await guardOfflineDownloadRequest(input)).toBeNull();
|
|
338
|
+
expect(mocks.limit).toHaveBeenCalledTimes(4);
|
|
339
|
+
});
|
|
340
|
+
});
|