@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.
- package/CHANGELOG.md +55 -0
- package/package.json +6 -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__/calendar-provider-readonly.test.ts +45 -0
- package/src/__tests__/calendar-recurrence-edit.test.ts +140 -0
- package/src/__tests__/calendar-recurrence.test.ts +479 -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__/permissions.test.tsx +21 -0
- package/src/__tests__/request-workspace.test.ts +63 -0
- package/src/__tests__/tutoring-teacher-hours.test.ts +274 -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/calendar-provider-readonly.ts +49 -0
- package/src/calendar-recurrence-edit.ts +148 -0
- package/src/calendar-recurrence-expansion.ts +131 -0
- package/src/calendar-recurrence-local.ts +45 -0
- package/src/calendar-recurrence-pattern.ts +54 -0
- package/src/calendar-recurrence-providers.ts +361 -0
- package/src/calendar-recurrence-validation.ts +180 -0
- package/src/calendar-recurrence.ts +5 -0
- package/src/calendar-utils.ts +41 -3
- package/src/common/metadata.test.ts +0 -6
- package/src/constants.ts +3 -0
- package/src/deployment-vault-permissions.tsx +26 -0
- package/src/desktop-signing-profiles.ts +20 -0
- package/src/google-calendar-import-fence.test.ts +182 -0
- package/src/google-calendar-import-fence.ts +166 -0
- package/src/next-config.ts +8 -1
- package/src/offline-download-guard.ts +160 -0
- package/src/permissions.tsx +2 -10
- 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/request-workspace.ts +29 -0
- package/src/seo-policy.json +196 -0
- package/src/seo.test.ts +168 -0
- package/src/seo.ts +49 -0
- package/src/tutoring-teacher-hours.ts +220 -0
- package/src/username-policy.json +87 -0
- package/src/username-policy.ts +18 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { Temporal } from '@js-temporal/polyfill';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
assessTeacherHoursSlot,
|
|
5
|
+
type HoursSlot,
|
|
6
|
+
resetTeacherHoursOverride,
|
|
7
|
+
resolveTeacherHours,
|
|
8
|
+
type StaffHours,
|
|
9
|
+
validateHoursFrame,
|
|
10
|
+
validateHoursWeek,
|
|
11
|
+
validateHoursWriteReceipt,
|
|
12
|
+
} from '../tutoring-teacher-hours';
|
|
13
|
+
|
|
14
|
+
const defaults: StaffHours = {
|
|
15
|
+
revision: '900719925474099312345',
|
|
16
|
+
frame: { timeZone: 'Asia/Ho_Chi_Minh', confirmed: true },
|
|
17
|
+
week: { 1: [{ start: '09:00', end: '17:00' }], 2: [] },
|
|
18
|
+
};
|
|
19
|
+
const monday: HoursSlot = {
|
|
20
|
+
date: '2026-10-05',
|
|
21
|
+
start: '09:00',
|
|
22
|
+
endDate: '2026-10-05',
|
|
23
|
+
end: '10:00',
|
|
24
|
+
};
|
|
25
|
+
const decide = (slot: HoursSlot, hours = defaults) =>
|
|
26
|
+
assessTeacherHoursSlot(hours, null, slot).state;
|
|
27
|
+
|
|
28
|
+
describe('teacher-hours authority and per-day inheritance', () => {
|
|
29
|
+
it('keeps teacher availability unknown without confirmed staff hours', () => {
|
|
30
|
+
expect(
|
|
31
|
+
resolveTeacherHours(null, null).every((day) => day.state === 'unknown')
|
|
32
|
+
).toBe(true);
|
|
33
|
+
expect(assessTeacherHoursSlot(null, null, monday)).toEqual({
|
|
34
|
+
state: 'unknown',
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('distinguishes missing staff day from explicitly closed day', () => {
|
|
39
|
+
const week = resolveTeacherHours(defaults, null);
|
|
40
|
+
expect(week[0]).toMatchObject({ state: 'hours', source: 'default' });
|
|
41
|
+
expect(week[1]).toMatchObject({ state: 'closed', source: 'default' });
|
|
42
|
+
expect(week[2]).toMatchObject({ state: 'unknown', source: 'unknown' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('replaces, never unions, one overridden day and inherits absent weekdays', () => {
|
|
46
|
+
const week = resolveTeacherHours(defaults, {
|
|
47
|
+
revision: 'override:1',
|
|
48
|
+
week: { 1: [{ start: '18:00', end: '20:00' }] },
|
|
49
|
+
});
|
|
50
|
+
expect(week[0]?.intervals).toEqual([{ start: '18:00', end: '20:00' }]);
|
|
51
|
+
expect(week[0]?.source).toBe('override');
|
|
52
|
+
expect(week[1]?.state).toBe('closed');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('explicit empty override closes the day; removing it inherits latest default', () => {
|
|
56
|
+
expect(
|
|
57
|
+
resolveTeacherHours(defaults, { revision: 'a', week: { 1: [] } })[0]
|
|
58
|
+
?.state
|
|
59
|
+
).toBe('closed');
|
|
60
|
+
const reset = resetTeacherHoursOverride('a', 'b');
|
|
61
|
+
expect(reset).toEqual({ revision: 'b', week: {} });
|
|
62
|
+
expect(
|
|
63
|
+
resolveTeacherHours({ ...defaults, week: { 1: [] } }, reset)[0]?.state
|
|
64
|
+
).toBe('closed');
|
|
65
|
+
expect(resolveTeacherHours(defaults, reset)[0]?.state).toBe('hours');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('cannot use custom overrides without a confirmed staff timezone', () => {
|
|
69
|
+
expect(
|
|
70
|
+
resolveTeacherHours(null, {
|
|
71
|
+
revision: 'a',
|
|
72
|
+
week: { 1: [{ start: '09:00', end: '17:00' }] },
|
|
73
|
+
})[0]?.state
|
|
74
|
+
).toBe('unknown');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns defensive interval copies', () => {
|
|
78
|
+
const week = resolveTeacherHours(defaults, null);
|
|
79
|
+
expect(week[0]?.intervals[0]).not.toBe(defaults.week[1]?.[0]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('lossless receipt and reset contract', () => {
|
|
84
|
+
it('retains opaque revisions above JS safe precision and tombstone after reset', () => {
|
|
85
|
+
const before = '900719925474099312345';
|
|
86
|
+
const after = '900719925474099312346';
|
|
87
|
+
expect(Number(before)).toBe(Number(after));
|
|
88
|
+
const reset = resetTeacherHoursOverride(before, after);
|
|
89
|
+
expect(reset.revision).toBe(after);
|
|
90
|
+
expect(reset.week).toEqual({});
|
|
91
|
+
expect(resetTeacherHoursOverride(null, before).revision).toBe(before);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('rejects reused same-content receipt revisions and malformed revisions', () => {
|
|
95
|
+
expect(() => validateHoursWriteReceipt('a', 'a')).toThrow();
|
|
96
|
+
expect(() => validateHoursWriteReceipt('a', '')).toThrow();
|
|
97
|
+
expect(() => validateHoursWriteReceipt(' ', 'b')).toThrow();
|
|
98
|
+
expect(() => validateHoursWriteReceipt(null, 'b')).not.toThrow();
|
|
99
|
+
// No pure helper claims to enforce storage CAS: stale/reset ABA needs atomic DB proof.
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('strict frame and weekly window validation', () => {
|
|
104
|
+
it.each(['auto', '', '+07:00', 'Not/AZone'])(
|
|
105
|
+
'rejects nonconfirmed IANA frame %s',
|
|
106
|
+
(timeZone) => {
|
|
107
|
+
expect(() => validateHoursFrame({ timeZone, confirmed: true })).toThrow();
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
it('rejects unconfirmed valid timezone', () => {
|
|
111
|
+
expect(() =>
|
|
112
|
+
validateHoursFrame({ timeZone: 'UTC', confirmed: false } as never)
|
|
113
|
+
).toThrow();
|
|
114
|
+
});
|
|
115
|
+
it.each([
|
|
116
|
+
{ 0: [] },
|
|
117
|
+
{ 8: [] },
|
|
118
|
+
{ 1: [{ start: '9:00', end: '10:00' }] },
|
|
119
|
+
{ 1: [{ start: '24:00', end: '24:00' }] },
|
|
120
|
+
{ 1: [{ start: '10:00', end: '09:00' }] },
|
|
121
|
+
{
|
|
122
|
+
1: [
|
|
123
|
+
{ start: '09:00', end: '11:00' },
|
|
124
|
+
{ start: '10:00', end: '12:00' },
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
1: [
|
|
129
|
+
{ start: '12:00', end: '13:00' },
|
|
130
|
+
{ start: '09:00', end: '10:00' },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
])('rejects malformed/overlapping/unsorted week %j', (week) => {
|
|
134
|
+
expect(() => validateHoursWeek(week)).toThrow();
|
|
135
|
+
});
|
|
136
|
+
it('allows half-open adjacency and explicit midnight end', () => {
|
|
137
|
+
expect(() =>
|
|
138
|
+
validateHoursWeek({
|
|
139
|
+
1: [
|
|
140
|
+
{ start: '00:00', end: '12:00' },
|
|
141
|
+
{ start: '12:00', end: '24:00' },
|
|
142
|
+
],
|
|
143
|
+
})
|
|
144
|
+
).not.toThrow();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('candidate coverage and actual Temporal boundary', () => {
|
|
149
|
+
it('allows exact boundaries and rejects outside hours', () => {
|
|
150
|
+
expect(decide({ ...monday, end: '17:00' })).toBe('allowed');
|
|
151
|
+
expect(decide({ ...monday, start: '08:59' })).toBe('closed');
|
|
152
|
+
expect(decide({ ...monday, end: '17:01' })).toBe('closed');
|
|
153
|
+
expect(
|
|
154
|
+
decide({ ...monday, date: '2026-10-07', endDate: '2026-10-07' })
|
|
155
|
+
).toBe('unknown');
|
|
156
|
+
});
|
|
157
|
+
it('covers adjacent intervals continuously without bridging a gap', () => {
|
|
158
|
+
const week = {
|
|
159
|
+
1: [
|
|
160
|
+
{ start: '09:00', end: '10:00' },
|
|
161
|
+
{ start: '10:00', end: '11:00' },
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
expect(decide({ ...monday, end: '11:00' }, { ...defaults, week })).toBe(
|
|
165
|
+
'allowed'
|
|
166
|
+
);
|
|
167
|
+
week[1][1] = { start: '10:01', end: '11:00' };
|
|
168
|
+
expect(decide({ ...monday, end: '11:00' }, { ...defaults, week })).toBe(
|
|
169
|
+
'closed'
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
it('requires explicit coverage on both days for overnight and Sunday wrap', () => {
|
|
173
|
+
const slot = {
|
|
174
|
+
date: '2026-10-11',
|
|
175
|
+
start: '23:00',
|
|
176
|
+
endDate: '2026-10-12',
|
|
177
|
+
end: '01:00',
|
|
178
|
+
};
|
|
179
|
+
const hours = {
|
|
180
|
+
...defaults,
|
|
181
|
+
week: {
|
|
182
|
+
7: [{ start: '22:00', end: '24:00' }],
|
|
183
|
+
1: [{ start: '00:00', end: '02:00' }],
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
expect(decide(slot, hours)).toBe('allowed');
|
|
187
|
+
expect(decide(slot, { ...hours, week: { 7: hours.week[7] } })).toBe(
|
|
188
|
+
'unknown'
|
|
189
|
+
);
|
|
190
|
+
expect(decide(slot, { ...hours, week: { ...hours.week, 1: [] } })).toBe(
|
|
191
|
+
'closed'
|
|
192
|
+
);
|
|
193
|
+
expect(
|
|
194
|
+
decide(
|
|
195
|
+
{ ...slot, end: '00:00' },
|
|
196
|
+
{ ...hours, week: { 7: hours.week[7] } }
|
|
197
|
+
)
|
|
198
|
+
).toBe('allowed');
|
|
199
|
+
});
|
|
200
|
+
it('rejects zero/negative/unbounded slots and invalid civil dates', () => {
|
|
201
|
+
for (const slot of [
|
|
202
|
+
{ ...monday, end: '09:00' },
|
|
203
|
+
{ ...monday, end: '08:00' },
|
|
204
|
+
{ ...monday, endDate: '2026-10-13' },
|
|
205
|
+
{ ...monday, date: '2026-02-30' },
|
|
206
|
+
{ ...monday, start: '9:00' },
|
|
207
|
+
])
|
|
208
|
+
expect(decide(slot)).toBe('invalid');
|
|
209
|
+
});
|
|
210
|
+
it('proves compatible Temporal conversion would move a nonexistent clock time', () => {
|
|
211
|
+
const local = Temporal.PlainDateTime.from('2026-03-08T02:30');
|
|
212
|
+
expect(local.toZonedDateTime('America/New_York').hour).toBe(3);
|
|
213
|
+
const hours = {
|
|
214
|
+
...defaults,
|
|
215
|
+
frame: { timeZone: 'America/New_York', confirmed: true as const },
|
|
216
|
+
week: { 7: [{ start: '00:00', end: '24:00' }] },
|
|
217
|
+
};
|
|
218
|
+
expect(
|
|
219
|
+
decide(
|
|
220
|
+
{
|
|
221
|
+
date: '2026-03-08',
|
|
222
|
+
start: '02:30',
|
|
223
|
+
endDate: '2026-03-08',
|
|
224
|
+
end: '04:00',
|
|
225
|
+
},
|
|
226
|
+
hours
|
|
227
|
+
)
|
|
228
|
+
).toBe('invalid');
|
|
229
|
+
expect(
|
|
230
|
+
decide(
|
|
231
|
+
{
|
|
232
|
+
date: '2026-11-01',
|
|
233
|
+
start: '01:30',
|
|
234
|
+
endDate: '2026-11-01',
|
|
235
|
+
end: '03:00',
|
|
236
|
+
},
|
|
237
|
+
hours
|
|
238
|
+
)
|
|
239
|
+
).toBe('invalid');
|
|
240
|
+
expect(
|
|
241
|
+
decide(
|
|
242
|
+
{
|
|
243
|
+
date: '2026-03-08',
|
|
244
|
+
start: '01:00',
|
|
245
|
+
endDate: '2026-03-08',
|
|
246
|
+
end: '04:00',
|
|
247
|
+
},
|
|
248
|
+
hours
|
|
249
|
+
)
|
|
250
|
+
).toBe('invalid');
|
|
251
|
+
expect(
|
|
252
|
+
decide(
|
|
253
|
+
{
|
|
254
|
+
date: '2026-11-01',
|
|
255
|
+
start: '00:30',
|
|
256
|
+
endDate: '2026-11-01',
|
|
257
|
+
end: '03:00',
|
|
258
|
+
},
|
|
259
|
+
hours
|
|
260
|
+
)
|
|
261
|
+
).toBe('invalid');
|
|
262
|
+
expect(
|
|
263
|
+
decide(
|
|
264
|
+
{
|
|
265
|
+
date: '2026-03-09',
|
|
266
|
+
start: '09:00',
|
|
267
|
+
endDate: '2026-03-09',
|
|
268
|
+
end: '10:00',
|
|
269
|
+
},
|
|
270
|
+
{ ...hours, week: defaults.week }
|
|
271
|
+
)
|
|
272
|
+
).toBe('allowed');
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { isReservedUsername, isValidNewUsername } from '../username-policy';
|
|
6
|
+
import reserved from '../username-policy.json';
|
|
7
|
+
|
|
8
|
+
describe('Canonical username policy', () => {
|
|
9
|
+
it.each([
|
|
10
|
+
'four',
|
|
11
|
+
'abc',
|
|
12
|
+
'_invalid',
|
|
13
|
+
'Uppercase',
|
|
14
|
+
'has-hyphen',
|
|
15
|
+
'a'.repeat(33),
|
|
16
|
+
])('rejects invalid new usernames %s', (value) => {
|
|
17
|
+
expect(isValidNewUsername(value)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it.each([
|
|
20
|
+
'apple',
|
|
21
|
+
'google',
|
|
22
|
+
'microsoft',
|
|
23
|
+
'support',
|
|
24
|
+
'admin',
|
|
25
|
+
'admin123',
|
|
26
|
+
'supportteam',
|
|
27
|
+
'official_admin',
|
|
28
|
+
's_u_p_p_o_r_t123',
|
|
29
|
+
'g_o_o_g_l_e',
|
|
30
|
+
'apple123',
|
|
31
|
+
'official_microsoft',
|
|
32
|
+
'google_support',
|
|
33
|
+
])('reserves common and branded identities %s', (value) => {
|
|
34
|
+
expect(isReservedUsername(value)).toBe(true);
|
|
35
|
+
expect(isValidNewUsername(value)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it.each([
|
|
38
|
+
'quiet_artist',
|
|
39
|
+
'my_world',
|
|
40
|
+
'pineapple',
|
|
41
|
+
'my_supportteam',
|
|
42
|
+
'river123',
|
|
43
|
+
'abcde',
|
|
44
|
+
'a'.repeat(32),
|
|
45
|
+
])('accepts a distinct creative username %s', (value) => {
|
|
46
|
+
expect(isValidNewUsername(value)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it('keeps every canonical reserved name enforced in the database migration', () => {
|
|
49
|
+
const sql = readFileSync(
|
|
50
|
+
resolve(
|
|
51
|
+
__dirname,
|
|
52
|
+
'../../../../apps/database/supabase/migrations/20261004004000_public_creator_identity.sql'
|
|
53
|
+
),
|
|
54
|
+
'utf8'
|
|
55
|
+
);
|
|
56
|
+
const seeds = [
|
|
57
|
+
...sql.matchAll(
|
|
58
|
+
/jsonb_array_elements_text\('([^']+)'::jsonb\),'(common|brand)'/g
|
|
59
|
+
),
|
|
60
|
+
];
|
|
61
|
+
expect(seeds).toHaveLength(2);
|
|
62
|
+
for (const [category, values] of [
|
|
63
|
+
['common', reserved.common],
|
|
64
|
+
['brand', reserved.brands],
|
|
65
|
+
] as const) {
|
|
66
|
+
const seed = seeds.find((match) => match[2] === category);
|
|
67
|
+
expect(JSON.parse(seed![1]!)).toEqual(values);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createAdminClient } from '@tuturuuu/supabase/next/server';
|
|
3
|
+
export class AccountServiceError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
readonly status: number,
|
|
6
|
+
message = 'Account request failed'
|
|
7
|
+
) {
|
|
8
|
+
super(message);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export interface AccountPrivateTransport {
|
|
12
|
+
rpc<T>(
|
|
13
|
+
name: string,
|
|
14
|
+
args: Record<string, unknown>
|
|
15
|
+
): Promise<{ data: T | null; error: { code?: string } | null }>;
|
|
16
|
+
}
|
|
17
|
+
export async function accountPrivateRpc<T>(
|
|
18
|
+
name: string,
|
|
19
|
+
args: Record<string, unknown>
|
|
20
|
+
): Promise<T> {
|
|
21
|
+
const admin = await createAdminClient({ noCookie: true });
|
|
22
|
+
const client = (
|
|
23
|
+
admin as unknown as { schema(name: string): AccountPrivateTransport }
|
|
24
|
+
).schema('private');
|
|
25
|
+
const result = await client.rpc<T>(name, args);
|
|
26
|
+
if (result.error || result.data === null) {
|
|
27
|
+
const code = result.error?.code;
|
|
28
|
+
throw new AccountServiceError(
|
|
29
|
+
code === '42501'
|
|
30
|
+
? 403
|
|
31
|
+
: (result.data === null && !result.error) || code === 'P0002'
|
|
32
|
+
? 404
|
|
33
|
+
: code === '40001' || code === '23505'
|
|
34
|
+
? 409
|
|
35
|
+
: code === '22023'
|
|
36
|
+
? 400
|
|
37
|
+
: code === '53300'
|
|
38
|
+
? 503
|
|
39
|
+
: 500
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return result.data;
|
|
43
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const AccountBenefitKey = z
|
|
3
|
+
.string()
|
|
4
|
+
.regex(/^(feature|early_access)\.[a-z][a-z0-9_.-]{0,79}$/)
|
|
5
|
+
.or(z.literal('ai_credits'));
|
|
6
|
+
export const AccountBenefitGrant = z
|
|
7
|
+
.object({
|
|
8
|
+
userId: z.guid(),
|
|
9
|
+
key: AccountBenefitKey,
|
|
10
|
+
amount: z.number().int().min(1).max(1_000_000),
|
|
11
|
+
reason: z.string().trim().min(3).max(500),
|
|
12
|
+
expiresAt: z.iso.datetime({ offset: true }).nullable(),
|
|
13
|
+
requestId: z.guid(),
|
|
14
|
+
})
|
|
15
|
+
.strict()
|
|
16
|
+
.superRefine((value, ctx) => {
|
|
17
|
+
if (value.key !== 'ai_credits' && value.amount !== 1)
|
|
18
|
+
ctx.addIssue({
|
|
19
|
+
code: 'custom',
|
|
20
|
+
message: 'Feature grants have amount one',
|
|
21
|
+
});
|
|
22
|
+
if (value.key === 'ai_credits' && value.expiresAt !== null)
|
|
23
|
+
ctx.addIssue({
|
|
24
|
+
code: 'custom',
|
|
25
|
+
message: 'Credits use the billing period expiry',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
export function hasAccountBenefit(
|
|
29
|
+
grants: {
|
|
30
|
+
benefit_key: string;
|
|
31
|
+
starts_at: string;
|
|
32
|
+
expires_at: string | null;
|
|
33
|
+
revoked_at: string | null;
|
|
34
|
+
}[],
|
|
35
|
+
key: string,
|
|
36
|
+
now = Date.now()
|
|
37
|
+
) {
|
|
38
|
+
return grants.some(
|
|
39
|
+
(grant) =>
|
|
40
|
+
grant.benefit_key === key &&
|
|
41
|
+
!grant.revoked_at &&
|
|
42
|
+
Date.parse(grant.starts_at) <= now &&
|
|
43
|
+
(!grant.expires_at || Date.parse(grant.expires_at) > now)
|
|
44
|
+
);
|
|
45
|
+
}
|
package/src/api-proxy-guard.ts
CHANGED
|
@@ -11,7 +11,12 @@ import {
|
|
|
11
11
|
getCachedTrustEntries,
|
|
12
12
|
hasCachedIpBlockAppealRelief,
|
|
13
13
|
} from './abuse-protection/edge-trust';
|
|
14
|
+
import {
|
|
15
|
+
isFinanceInvoiceCreateSupportRead,
|
|
16
|
+
isFinanceRead,
|
|
17
|
+
} from './api-proxy-read-policy';
|
|
14
18
|
import { DEV_MODE, MAX_PAYLOAD_SIZE } from './constants';
|
|
19
|
+
import { guardOfflineDownloadRequest } from './offline-download-guard';
|
|
15
20
|
import { validateRequestEmojiLimit } from './request-emoji-limit';
|
|
16
21
|
import { enforceRequiredMfaRequest } from './required-mfa-runtime';
|
|
17
22
|
import {
|
|
@@ -112,23 +117,6 @@ type RateLimiter = {
|
|
|
112
117
|
const limiterCache = new Map<string, Limiters>();
|
|
113
118
|
const GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN =
|
|
114
119
|
/^sb-[a-z0-9-]+-auth-token(?:\.\d+)?$/i;
|
|
115
|
-
const UUID_PATH_SEGMENT =
|
|
116
|
-
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
|
117
|
-
const FINANCE_INVOICE_CREATE_SUPPORT_READ_PATH_PATTERN = new RegExp(
|
|
118
|
-
`^/api/v1/workspaces/[^/]+/(?:finance/invoices(?:/subscription/context)?|inventory/products|promotions|settings/(?:configs|[^/]+)|user-groups(?:/linked-products|/${UUID_PATH_SEGMENT}/linked-products)|users(?:/${UUID_PATH_SEGMENT}(?:/(?:linked-promotions|referral-discounts|user-groups))?)?|wallets)/?$`,
|
|
119
|
-
'u'
|
|
120
|
-
);
|
|
121
|
-
const FINANCE_INVOICE_TRANSACTION_CATEGORIES_PATH_PATTERN =
|
|
122
|
-
/^\/api\/workspaces\/[^/]+\/transactions\/categories\/?$/u;
|
|
123
|
-
const FINANCE_READ_PATH_PATTERNS = [
|
|
124
|
-
/^\/api\/workspaces\/[^/]+\/finance(?:\/|$)/u,
|
|
125
|
-
/^\/api\/workspaces\/[^/]+\/transactions(?:\/|$)/u,
|
|
126
|
-
/^\/api\/workspaces\/[^/]+\/wallets(?:\/|$)/u,
|
|
127
|
-
/^\/api\/workspaces\/[^/]+\/tags(?:\/|$)/u,
|
|
128
|
-
/^\/api\/v1\/workspaces\/[^/]+\/finance(?:\/|$)/u,
|
|
129
|
-
/^\/api\/v1\/workspaces\/[^/]+\/wallets(?:\/|$)/u,
|
|
130
|
-
] as const;
|
|
131
|
-
|
|
132
120
|
const NO_READ_RATE_LIMITS: RateLimitConfig[] = [];
|
|
133
121
|
|
|
134
122
|
function parsePositiveIntEnv(
|
|
@@ -391,31 +379,6 @@ const FINANCE_READ_RATE_LIMITS: RateLimitProfile = {
|
|
|
391
379
|
mutate: DEFAULT_MUTATE_RATE_LIMITS,
|
|
392
380
|
};
|
|
393
381
|
|
|
394
|
-
function isFinanceInvoiceCreateSupportRead(req: NextRequest) {
|
|
395
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
396
|
-
return false;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
return (
|
|
400
|
-
FINANCE_INVOICE_CREATE_SUPPORT_READ_PATH_PATTERN.test(
|
|
401
|
-
req.nextUrl.pathname
|
|
402
|
-
) ||
|
|
403
|
-
FINANCE_INVOICE_TRANSACTION_CATEGORIES_PATH_PATTERN.test(
|
|
404
|
-
req.nextUrl.pathname
|
|
405
|
-
)
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
function isFinanceRead(req: NextRequest) {
|
|
410
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
411
|
-
return false;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
return FINANCE_READ_PATH_PATTERNS.some((pattern) =>
|
|
415
|
-
pattern.test(req.nextUrl.pathname)
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
382
|
function isUsersDatabaseReadOverPost(req: NextRequest) {
|
|
420
383
|
if (req.method !== 'POST') {
|
|
421
384
|
return false;
|
|
@@ -1352,6 +1315,9 @@ export async function guardApiProxyRequest(
|
|
|
1352
1315
|
}
|
|
1353
1316
|
}
|
|
1354
1317
|
|
|
1318
|
+
const offlineResponse = await guardOfflineDownloadRequest(req);
|
|
1319
|
+
if (offlineResponse) return offlineResponse;
|
|
1320
|
+
|
|
1355
1321
|
const allowDescriptionYjsState =
|
|
1356
1322
|
/\/api\/v1\/workspaces\/[^/]+\/tasks\/[^/]+\/description$/.test(
|
|
1357
1323
|
req.nextUrl.pathname
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
|
|
3
|
+
const UUID_PATH_SEGMENT =
|
|
4
|
+
'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
|
|
5
|
+
const FINANCE_INVOICE_CREATE_SUPPORT_READ_PATH_PATTERN = new RegExp(
|
|
6
|
+
`^/api/v1/workspaces/[^/]+/(?:finance/invoices(?:/subscription/context)?|inventory/products|promotions|settings/(?:configs|(?!permissions(?:/|$)|members(?:/|$))[^/]+)|user-groups(?:/linked-products|/${UUID_PATH_SEGMENT}/linked-products)|users(?:/${UUID_PATH_SEGMENT}(?:/(?:linked-promotions|referral-discounts|user-groups))?)?|wallets)/?$`,
|
|
7
|
+
'u'
|
|
8
|
+
);
|
|
9
|
+
const FINANCE_INVOICE_TRANSACTION_CATEGORIES_PATH_PATTERN =
|
|
10
|
+
/^\/api\/workspaces\/[^/]+\/transactions\/categories\/?$/u;
|
|
11
|
+
const FINANCE_READ_PATH_PATTERNS = [
|
|
12
|
+
/^\/api\/workspaces\/[^/]+\/finance(?:\/|$)/u,
|
|
13
|
+
/^\/api\/workspaces\/[^/]+\/transactions(?:\/|$)/u,
|
|
14
|
+
/^\/api\/workspaces\/[^/]+\/wallets(?:\/|$)/u,
|
|
15
|
+
/^\/api\/workspaces\/[^/]+\/tags(?:\/|$)/u,
|
|
16
|
+
/^\/api\/v1\/workspaces\/[^/]+\/finance(?:\/|$)/u,
|
|
17
|
+
/^\/api\/v1\/workspaces\/[^/]+\/wallets(?:\/|$)/u,
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export function isFinanceInvoiceCreateSupportRead(req: NextRequest) {
|
|
21
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
FINANCE_INVOICE_CREATE_SUPPORT_READ_PATH_PATTERN.test(
|
|
27
|
+
req.nextUrl.pathname
|
|
28
|
+
) ||
|
|
29
|
+
FINANCE_INVOICE_TRANSACTION_CATEGORIES_PATH_PATTERN.test(
|
|
30
|
+
req.nextUrl.pathname
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isFinanceRead(req: NextRequest) {
|
|
36
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return FINANCE_READ_PATH_PATTERNS.some((pattern) =>
|
|
41
|
+
pattern.test(req.nextUrl.pathname)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Internal-only transport marker; never written back to a provider. */
|
|
2
|
+
const marker = '__tuturuuuProviderReadonlyRecurrence';
|
|
3
|
+
export type ProviderReadonlyRecurrence = {
|
|
4
|
+
state: 'unsupported';
|
|
5
|
+
master_id: string;
|
|
6
|
+
provider: 'google' | 'microsoft';
|
|
7
|
+
};
|
|
8
|
+
export function markProviderRecurrenceReadonly<T extends object>(
|
|
9
|
+
event: T,
|
|
10
|
+
provider: ProviderReadonlyRecurrence['provider'],
|
|
11
|
+
masterId: string
|
|
12
|
+
): T {
|
|
13
|
+
return {
|
|
14
|
+
...event,
|
|
15
|
+
[marker]: { state: 'unsupported', master_id: masterId, provider },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function providerReadonlyProjection(event: unknown): {
|
|
19
|
+
locked?: true;
|
|
20
|
+
scheduling_metadata?: { provider_recurrence: ProviderReadonlyRecurrence };
|
|
21
|
+
} {
|
|
22
|
+
if (!event || typeof event !== 'object') return {};
|
|
23
|
+
const value = (event as Record<string, unknown>)[marker];
|
|
24
|
+
if (!value || typeof value !== 'object') return {};
|
|
25
|
+
const entry = value as Partial<ProviderReadonlyRecurrence>;
|
|
26
|
+
if (
|
|
27
|
+
entry.state !== 'unsupported' ||
|
|
28
|
+
typeof entry.master_id !== 'string' ||
|
|
29
|
+
!entry.master_id ||
|
|
30
|
+
!['google', 'microsoft'].includes(entry.provider ?? '')
|
|
31
|
+
)
|
|
32
|
+
return {};
|
|
33
|
+
return {
|
|
34
|
+
locked: true,
|
|
35
|
+
scheduling_metadata: {
|
|
36
|
+
provider_recurrence: entry as ProviderReadonlyRecurrence,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function isProviderRecurrenceReadonly(event: unknown): boolean {
|
|
41
|
+
if (!event || typeof event !== 'object') return false;
|
|
42
|
+
const metadata = (event as { scheduling_metadata?: unknown })
|
|
43
|
+
.scheduling_metadata;
|
|
44
|
+
if (!metadata || typeof metadata !== 'object') return false;
|
|
45
|
+
return (
|
|
46
|
+
(metadata as { provider_recurrence?: { state?: unknown } })
|
|
47
|
+
.provider_recurrence?.state === 'unsupported'
|
|
48
|
+
);
|
|
49
|
+
}
|