@tuturuuu/utils 0.33.3 → 0.35.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/package.json +6 -4
  3. package/src/__tests__/api-proxy-bearer-trust.test.ts +3 -0
  4. package/src/__tests__/api-proxy-guard.test.ts +6 -13
  5. package/src/__tests__/api-proxy-offline-response.test.ts +48 -0
  6. package/src/__tests__/api-proxy-test-request.ts +19 -0
  7. package/src/__tests__/calendar-provider-readonly.test.ts +45 -0
  8. package/src/__tests__/calendar-recurrence-edit.test.ts +140 -0
  9. package/src/__tests__/calendar-recurrence.test.ts +479 -0
  10. package/src/__tests__/daily-operation-download-isolation.test.ts +137 -0
  11. package/src/__tests__/offline-download-guard.test.ts +340 -0
  12. package/src/__tests__/permissions.test.tsx +21 -0
  13. package/src/__tests__/request-workspace.test.ts +63 -0
  14. package/src/__tests__/tutoring-teacher-hours.test.ts +274 -0
  15. package/src/__tests__/username-policy.test.ts +70 -0
  16. package/src/account-benefits-server.ts +43 -0
  17. package/src/account-benefits.ts +45 -0
  18. package/src/api-proxy-guard.ts +8 -42
  19. package/src/api-proxy-read-policy.ts +43 -0
  20. package/src/calendar-provider-readonly.ts +49 -0
  21. package/src/calendar-recurrence-edit.ts +148 -0
  22. package/src/calendar-recurrence-expansion.ts +131 -0
  23. package/src/calendar-recurrence-local.ts +45 -0
  24. package/src/calendar-recurrence-pattern.ts +54 -0
  25. package/src/calendar-recurrence-providers.ts +361 -0
  26. package/src/calendar-recurrence-validation.ts +180 -0
  27. package/src/calendar-recurrence.ts +5 -0
  28. package/src/calendar-utils.ts +41 -3
  29. package/src/common/metadata.test.ts +0 -6
  30. package/src/constants.ts +3 -0
  31. package/src/deployment-vault-permissions.tsx +26 -0
  32. package/src/desktop-signing-profiles.ts +20 -0
  33. package/src/google-calendar-import-fence.test.ts +182 -0
  34. package/src/google-calendar-import-fence.ts +166 -0
  35. package/src/next-config.ts +8 -1
  36. package/src/offline-download-guard.ts +160 -0
  37. package/src/permissions.tsx +2 -10
  38. package/src/platform-release.test.ts +1 -1
  39. package/src/platform-release.ts +1 -1
  40. package/src/playground-schema.test.ts +119 -0
  41. package/src/playground-schema.ts +173 -0
  42. package/src/playground-templates.ts +65 -0
  43. package/src/release-note-policy-core.cjs +28 -0
  44. package/src/release-note-policy-core.d.cts +5 -0
  45. package/src/release-note-policy.json +9 -0
  46. package/src/release-note-policy.ts +4 -0
  47. package/src/request-workspace.ts +29 -0
  48. package/src/seo-policy.json +196 -0
  49. package/src/seo.test.ts +168 -0
  50. package/src/seo.ts +49 -0
  51. package/src/tutoring-teacher-hours.ts +220 -0
  52. package/src/username-policy.json +87 -0
  53. package/src/username-policy.ts +18 -0
  54. package/vitest.config.ts +7 -0
@@ -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
+ });
@@ -46,6 +46,27 @@ describe('workspace permission catalog', () => {
46
46
  expect(rootPermissionIds).toContain('manage_git_repositories');
47
47
  });
48
48
 
49
+ it('exposes both deployment vault permissions only in root or full catalogs', () => {
50
+ for (const id of [
51
+ 'manage_mobile_deployment_vault',
52
+ 'manage_desktop_deployment_vault',
53
+ ]) {
54
+ expect(
55
+ permissions({ wsId: workspaceId, user: null }).map((entry) => entry.id)
56
+ ).not.toContain(id);
57
+ expect(
58
+ permissions({ wsId: ROOT_WORKSPACE_ID, user: null }).map(
59
+ (entry) => entry.id
60
+ )
61
+ ).toContain(id);
62
+ expect(
63
+ permissions({ wsId: workspaceId, user: null, catalog: 'full' }).map(
64
+ (entry) => entry.id
65
+ )
66
+ ).toContain(id);
67
+ }
68
+ });
69
+
49
70
  it('keeps internal account permission translations in shared role editors', () => {
50
71
  for (const app of ['cms', 'infrastructure', 'inventory', 'web']) {
51
72
  for (const locale of ['en', 'vi']) {
@@ -0,0 +1,63 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { read, entries } = vi.hoisted(() => ({
4
+ read: vi.fn(),
5
+ entries: new Map<string, unknown>(),
6
+ }));
7
+ vi.mock('../workspace-helper', () => ({ getWorkspace: read }));
8
+ // One request's cache tests primitive identity keys. React owns its lifetime.
9
+ vi.mock('react', () => ({
10
+ cache:
11
+ (fn: (...args: unknown[]) => unknown) =>
12
+ (...args: unknown[]) => {
13
+ const key = JSON.stringify(args);
14
+ if (!entries.has(key)) entries.set(key, fn(...args));
15
+ return entries.get(key);
16
+ },
17
+ }));
18
+
19
+ import { getRequestWorkspace } from '../request-workspace';
20
+
21
+ describe('request workspace identity', () => {
22
+ beforeEach(() => {
23
+ entries.clear();
24
+ read.mockReset();
25
+ read.mockResolvedValue({ id: 'workspace' });
26
+ });
27
+ it('coalesces equal principals supplied as separate objects', async () => {
28
+ await Promise.all([
29
+ getRequestWorkspace('personal', {
30
+ useAdmin: true,
31
+ user: { id: 'actor' },
32
+ }),
33
+ getRequestWorkspace('personal', {
34
+ useAdmin: true,
35
+ user: { id: 'actor' },
36
+ }),
37
+ ]);
38
+ expect(read).toHaveBeenCalledTimes(1);
39
+ expect(read).toHaveBeenCalledWith('personal', {
40
+ useAdmin: true,
41
+ user: { id: 'actor', email: null },
42
+ });
43
+ });
44
+ it('separates actors, emails, workspace identifiers and access modes', async () => {
45
+ const user = { id: 'actor', email: 'actor@example.test' };
46
+ await getRequestWorkspace('personal', { user });
47
+ await getRequestWorkspace('personal', { user: { ...user, id: 'other' } });
48
+ await getRequestWorkspace('personal', { user: { ...user, email: null } });
49
+ await getRequestWorkspace('other', { user });
50
+ await getRequestWorkspace('personal', { useAdmin: true, user });
51
+ expect(read).toHaveBeenCalledTimes(5);
52
+ });
53
+ it('leaves authentication to the existing reader without a principal', async () => {
54
+ await getRequestWorkspace('personal');
55
+ expect(read).toHaveBeenCalledWith('personal', { useAdmin: false });
56
+ });
57
+ it('does not retain results in the next simulated request', async () => {
58
+ await getRequestWorkspace('personal');
59
+ entries.clear();
60
+ await getRequestWorkspace('personal');
61
+ expect(read).toHaveBeenCalledTimes(2);
62
+ });
63
+ });