@tuturuuu/utils 0.9.0 → 0.11.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/package.json +3 -3
- package/src/__tests__/api-proxy-guard.test.ts +146 -0
- package/src/__tests__/task-description-codec.test.ts +110 -0
- package/src/abuse-protection/__tests__/edge-trust.test.ts +41 -0
- package/src/abuse-protection/edge-trust.ts +67 -0
- package/src/api-proxy-guard.ts +71 -10
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/task-description-codec.ts +457 -0
- package/src/yjs-task-description.ts +13 -21
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.11.0](https://github.com/tutur3u/platform/compare/utils-v0.10.0...utils-v0.11.0) (2026-06-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **infrastructure:** add rate-limit appeals ([f1e50e9](https://github.com/tutur3u/platform/commit/f1e50e9fd805dca6c80da8daab60ed24199f24ab))
|
|
9
|
+
|
|
10
|
+
## [0.10.0](https://github.com/tutur3u/platform/compare/utils-v0.9.0...utils-v0.10.0) (2026-06-26)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **cli:** support task description editing ([c72390b](https://github.com/tutur3u/platform/commit/c72390b909054af596ccbed8af6ded9f266dfdd4))
|
|
16
|
+
|
|
3
17
|
## [0.9.0](https://github.com/tutur3u/platform/compare/utils-v0.8.0...utils-v0.9.0) (2026-06-24)
|
|
4
18
|
|
|
5
19
|
|
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.11.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
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.
|
|
27
|
+
"@tuturuuu/internal-api": "0.12.0",
|
|
28
28
|
"@tuturuuu/supabase": "0.4.0",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.0",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"zod": "^4.4.3"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@tuturuuu/types": "0.
|
|
48
|
+
"@tuturuuu/types": "0.13.0",
|
|
49
49
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
50
50
|
"@types/diff": "^8.0.0",
|
|
51
51
|
"@types/node": "^26.0.0",
|
|
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
10
10
|
isBlocked: vi.fn(),
|
|
11
11
|
validateEmoji: vi.fn(),
|
|
12
12
|
getTrustEntries: vi.fn(),
|
|
13
|
+
hasAppealRelief: vi.fn(),
|
|
13
14
|
}));
|
|
14
15
|
|
|
15
16
|
vi.mock('@upstash/ratelimit', () => {
|
|
@@ -53,6 +54,8 @@ vi.mock('../abuse-protection/edge', () => ({
|
|
|
53
54
|
|
|
54
55
|
vi.mock('../abuse-protection/edge-trust', () => ({
|
|
55
56
|
getCachedTrustEntries: (keys: string[]) => mocks.getTrustEntries(keys),
|
|
57
|
+
hasCachedIpBlockAppealRelief: (sessionKey: string, ipKey: string) =>
|
|
58
|
+
mocks.hasAppealRelief(sessionKey, ipKey),
|
|
56
59
|
}));
|
|
57
60
|
|
|
58
61
|
vi.mock('../request-emoji-limit', () => ({
|
|
@@ -104,8 +107,10 @@ describe('guardApiProxyRequest', () => {
|
|
|
104
107
|
mocks.isBlocked.mockReset();
|
|
105
108
|
mocks.validateEmoji.mockReset();
|
|
106
109
|
mocks.getTrustEntries.mockReset();
|
|
110
|
+
mocks.hasAppealRelief.mockReset();
|
|
107
111
|
// Default: no cached trust (untrusted). Tests opt in by overriding.
|
|
108
112
|
mocks.getTrustEntries.mockResolvedValue(new Map<string, { m: number }>());
|
|
113
|
+
mocks.hasAppealRelief.mockResolvedValue(false);
|
|
109
114
|
});
|
|
110
115
|
|
|
111
116
|
afterEach(() => {
|
|
@@ -172,6 +177,147 @@ describe('guardApiProxyRequest', () => {
|
|
|
172
177
|
expect(mocks.limit).not.toHaveBeenCalled();
|
|
173
178
|
});
|
|
174
179
|
|
|
180
|
+
it('allows a blocked IP to submit the rate-limit appeal endpoint only', async () => {
|
|
181
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
182
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
183
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
184
|
+
mocks.redis.mockReturnValue({});
|
|
185
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
186
|
+
mocks.isBlocked.mockResolvedValue({
|
|
187
|
+
expiresAt: new Date(Date.now() + 30_000),
|
|
188
|
+
reason: 'abuse',
|
|
189
|
+
blockLevel: 'temporary',
|
|
190
|
+
});
|
|
191
|
+
mocks.limit
|
|
192
|
+
.mockResolvedValueOnce({
|
|
193
|
+
success: true,
|
|
194
|
+
limit: 1,
|
|
195
|
+
remaining: 0,
|
|
196
|
+
reset: Date.now() + 15_000,
|
|
197
|
+
})
|
|
198
|
+
.mockResolvedValueOnce({
|
|
199
|
+
success: true,
|
|
200
|
+
limit: 3,
|
|
201
|
+
remaining: 2,
|
|
202
|
+
reset: Date.now() + 15_000,
|
|
203
|
+
})
|
|
204
|
+
.mockResolvedValueOnce({
|
|
205
|
+
success: true,
|
|
206
|
+
limit: 10,
|
|
207
|
+
remaining: 9,
|
|
208
|
+
reset: Date.now() + 15_000,
|
|
209
|
+
});
|
|
210
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
211
|
+
|
|
212
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
213
|
+
await import('../api-proxy-guard.js');
|
|
214
|
+
clearApiProxyGuardLimiterCache();
|
|
215
|
+
|
|
216
|
+
const response = await guardApiProxyRequest(
|
|
217
|
+
makeRequest('/api/v1/rate-limit-appeals', 'POST', {
|
|
218
|
+
cookie: 'tuturuuu_app_session=session-value',
|
|
219
|
+
}),
|
|
220
|
+
{
|
|
221
|
+
prefixBase: 'proxy:test:api',
|
|
222
|
+
}
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(response).toBeNull();
|
|
226
|
+
expect(mocks.hasAppealRelief).not.toHaveBeenCalled();
|
|
227
|
+
expect(mocks.limit).toHaveBeenCalledTimes(3);
|
|
228
|
+
expect(mocks.ratelimitConfigs).toContainEqual({
|
|
229
|
+
limit: 1,
|
|
230
|
+
window: '1 m',
|
|
231
|
+
});
|
|
232
|
+
expect(mocks.ratelimitPrefixes).toContain(
|
|
233
|
+
'proxy:test:api:rate-limit-appeal:anonymous:mutate:minute'
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('allows a blocked IP through normal APIs only with matching temporary session relief', async () => {
|
|
238
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
239
|
+
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
240
|
+
vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
|
|
241
|
+
mocks.redis.mockReturnValue({});
|
|
242
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
243
|
+
mocks.isBlocked.mockResolvedValue({
|
|
244
|
+
expiresAt: new Date(Date.now() + 30_000),
|
|
245
|
+
reason: 'abuse',
|
|
246
|
+
blockLevel: 'temporary',
|
|
247
|
+
});
|
|
248
|
+
mocks.hasAppealRelief.mockResolvedValueOnce(true);
|
|
249
|
+
mocks.limit
|
|
250
|
+
.mockResolvedValueOnce({
|
|
251
|
+
success: true,
|
|
252
|
+
limit: 30,
|
|
253
|
+
remaining: 29,
|
|
254
|
+
reset: Date.now() + 15_000,
|
|
255
|
+
})
|
|
256
|
+
.mockResolvedValueOnce({
|
|
257
|
+
success: true,
|
|
258
|
+
limit: 120,
|
|
259
|
+
remaining: 119,
|
|
260
|
+
reset: Date.now() + 15_000,
|
|
261
|
+
})
|
|
262
|
+
.mockResolvedValueOnce({
|
|
263
|
+
success: true,
|
|
264
|
+
limit: 600,
|
|
265
|
+
remaining: 599,
|
|
266
|
+
reset: Date.now() + 15_000,
|
|
267
|
+
});
|
|
268
|
+
mocks.validateEmoji.mockResolvedValue(null);
|
|
269
|
+
|
|
270
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
271
|
+
await import('../api-proxy-guard.js');
|
|
272
|
+
clearApiProxyGuardLimiterCache();
|
|
273
|
+
|
|
274
|
+
const response = await guardApiProxyRequest(
|
|
275
|
+
makeRequest('/api/test', 'POST', {
|
|
276
|
+
cookie: 'tuturuuu_app_session=session-value',
|
|
277
|
+
}),
|
|
278
|
+
{
|
|
279
|
+
prefixBase: 'proxy:test:api',
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
expect(response).toBeNull();
|
|
284
|
+
expect(mocks.hasAppealRelief).toHaveBeenCalledWith(
|
|
285
|
+
expect.stringMatching(/^session:/),
|
|
286
|
+
'ip:1.2.3.4'
|
|
287
|
+
);
|
|
288
|
+
expect(mocks.limit).toHaveBeenCalledTimes(3);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('keeps unrelated blocked sessions without temporary relief blocked', async () => {
|
|
292
|
+
vi.stubEnv('NODE_ENV', 'production');
|
|
293
|
+
mocks.extractIp.mockReturnValue('1.2.3.4');
|
|
294
|
+
mocks.isBlocked.mockResolvedValue({
|
|
295
|
+
expiresAt: new Date(Date.now() + 30_000),
|
|
296
|
+
reason: 'abuse',
|
|
297
|
+
blockLevel: 'temporary',
|
|
298
|
+
});
|
|
299
|
+
mocks.hasAppealRelief.mockResolvedValue(false);
|
|
300
|
+
|
|
301
|
+
const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
|
|
302
|
+
await import('../api-proxy-guard.js');
|
|
303
|
+
clearApiProxyGuardLimiterCache();
|
|
304
|
+
|
|
305
|
+
const response = await guardApiProxyRequest(
|
|
306
|
+
makeRequest('/api/test', 'POST', {
|
|
307
|
+
cookie: 'tuturuuu_app_session=other-session',
|
|
308
|
+
}),
|
|
309
|
+
{
|
|
310
|
+
prefixBase: 'proxy:test:api',
|
|
311
|
+
}
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
expect(response?.status).toBe(429);
|
|
315
|
+
expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
|
|
316
|
+
'ip-already-blocked'
|
|
317
|
+
);
|
|
318
|
+
expect(mocks.limit).not.toHaveBeenCalled();
|
|
319
|
+
});
|
|
320
|
+
|
|
175
321
|
it('rate limits anonymous generic GET routes', async () => {
|
|
176
322
|
vi.stubEnv('NODE_ENV', 'production');
|
|
177
323
|
vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
createTaskDescriptionPayload,
|
|
4
|
+
decodeTaskDescriptionYjsState,
|
|
5
|
+
encodeTaskDescriptionYjsState,
|
|
6
|
+
parseMarkdownTaskDescriptionContent,
|
|
7
|
+
parsePersistedTaskDescriptionContent,
|
|
8
|
+
parseTaskDescriptionInput,
|
|
9
|
+
parseTaskDescriptionJson,
|
|
10
|
+
taskDescriptionPlainText,
|
|
11
|
+
taskDescriptionYjsStateFromBase64,
|
|
12
|
+
taskDescriptionYjsStateFromBytesJson,
|
|
13
|
+
taskDescriptionYjsStateToBase64,
|
|
14
|
+
} from '../task-description-codec';
|
|
15
|
+
|
|
16
|
+
describe('task description codec', () => {
|
|
17
|
+
it('creates storage payloads from plain text', () => {
|
|
18
|
+
const payload = createTaskDescriptionPayload(
|
|
19
|
+
'First paragraph\n\nSecond paragraph'
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
expect(payload.description).toContain('First paragraph');
|
|
23
|
+
expect(payload.description_yjs_state?.length ?? 0).toBeGreaterThan(0);
|
|
24
|
+
expect(taskDescriptionPlainText(payload.content)).toContain(
|
|
25
|
+
'Second paragraph'
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('parses common markdown blocks into TipTap content', () => {
|
|
30
|
+
const content = parseMarkdownTaskDescriptionContent(
|
|
31
|
+
[
|
|
32
|
+
'# Release notes',
|
|
33
|
+
'',
|
|
34
|
+
'- [x] Confirm CLI auth',
|
|
35
|
+
'- [ ] Ship task descriptions',
|
|
36
|
+
'',
|
|
37
|
+
'> Keep this scoped',
|
|
38
|
+
'',
|
|
39
|
+
'Visit [Tuturuuu](https://tuturuuu.com) with **bold** text.',
|
|
40
|
+
'',
|
|
41
|
+
'```ts',
|
|
42
|
+
'const shipped = true;',
|
|
43
|
+
'```',
|
|
44
|
+
].join('\n')
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const plainText = taskDescriptionPlainText(content);
|
|
48
|
+
|
|
49
|
+
expect(plainText).toContain('# Release notes');
|
|
50
|
+
expect(plainText).toContain('[x] Confirm CLI auth');
|
|
51
|
+
expect(plainText).toContain('> Keep this scoped');
|
|
52
|
+
expect(plainText).toContain('const shipped = true;');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('round-trips TipTap content through Yjs base64', () => {
|
|
56
|
+
const content = parseTaskDescriptionInput(
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
attrs: { level: 2 },
|
|
61
|
+
content: [{ text: 'Task heading', type: 'text' }],
|
|
62
|
+
type: 'heading',
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
type: 'doc',
|
|
66
|
+
}),
|
|
67
|
+
'json'
|
|
68
|
+
);
|
|
69
|
+
const yjsState = encodeTaskDescriptionYjsState(content);
|
|
70
|
+
const encoded = taskDescriptionYjsStateToBase64(yjsState);
|
|
71
|
+
const decoded = decodeTaskDescriptionYjsState(
|
|
72
|
+
taskDescriptionYjsStateFromBase64(encoded ?? '')
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(taskDescriptionPlainText(decoded)).toContain('Task heading');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects explicit non-doc JSON input', () => {
|
|
79
|
+
expect(() => parseTaskDescriptionJson('{"text":"not a doc"}')).toThrow(
|
|
80
|
+
/TipTap doc/
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('validates bytes JSON input', () => {
|
|
85
|
+
expect(taskDescriptionYjsStateFromBytesJson('[1,2,255]')).toEqual([
|
|
86
|
+
1, 2, 255,
|
|
87
|
+
]);
|
|
88
|
+
expect(() => taskDescriptionYjsStateFromBytesJson('[1,999]')).toThrow(
|
|
89
|
+
/array of byte/
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('prefers persisted Yjs state over stale description JSON', () => {
|
|
94
|
+
const fresh = createTaskDescriptionPayload('Fresh state');
|
|
95
|
+
const content = parsePersistedTaskDescriptionContent({
|
|
96
|
+
description: JSON.stringify({
|
|
97
|
+
content: [
|
|
98
|
+
{
|
|
99
|
+
content: [{ text: 'Stale projection', type: 'text' }],
|
|
100
|
+
type: 'paragraph',
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
type: 'doc',
|
|
104
|
+
}),
|
|
105
|
+
description_yjs_state: fresh.description_yjs_state,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(taskDescriptionPlainText(content)).toContain('Fresh state');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
4
|
getClient: vi.fn(),
|
|
5
|
+
get: vi.fn(),
|
|
5
6
|
mget: vi.fn(),
|
|
6
7
|
set: vi.fn(),
|
|
7
8
|
}));
|
|
@@ -13,6 +14,7 @@ vi.mock('../../upstash-rest', () => ({
|
|
|
13
14
|
function mockRedisClient() {
|
|
14
15
|
mocks.getClient.mockResolvedValue({
|
|
15
16
|
mget: (...keys: string[]) => mocks.mget(...keys),
|
|
17
|
+
get: (key: string) => mocks.get(key),
|
|
16
18
|
set: (key: string, value: unknown, options?: unknown) =>
|
|
17
19
|
mocks.set(key, value, options),
|
|
18
20
|
});
|
|
@@ -22,6 +24,7 @@ describe('edge-trust cache', () => {
|
|
|
22
24
|
beforeEach(() => {
|
|
23
25
|
vi.resetModules();
|
|
24
26
|
mocks.getClient.mockReset();
|
|
27
|
+
mocks.get.mockReset();
|
|
25
28
|
mocks.mget.mockReset();
|
|
26
29
|
mocks.set.mockReset();
|
|
27
30
|
});
|
|
@@ -255,4 +258,42 @@ describe('edge-trust cache', () => {
|
|
|
255
258
|
ex: 900,
|
|
256
259
|
});
|
|
257
260
|
});
|
|
261
|
+
|
|
262
|
+
it('writes and reads a session plus IP scoped appeal relief key', async () => {
|
|
263
|
+
mockRedisClient();
|
|
264
|
+
mocks.set.mockResolvedValue('OK');
|
|
265
|
+
mocks.get.mockResolvedValue('1');
|
|
266
|
+
|
|
267
|
+
const {
|
|
268
|
+
buildIpBlockAppealReliefKey,
|
|
269
|
+
hasCachedIpBlockAppealRelief,
|
|
270
|
+
setCachedIpBlockAppealRelief,
|
|
271
|
+
} = await import('../edge-trust.js');
|
|
272
|
+
|
|
273
|
+
expect(buildIpBlockAppealReliefKey('session:abc', 'ip:203.0.113.10')).toBe(
|
|
274
|
+
'trust:appeal-relief:session:abc:ip:203.0.113.10'
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
await setCachedIpBlockAppealRelief('session:abc', 'ip:203.0.113.10', 600);
|
|
278
|
+
expect(mocks.set).toHaveBeenCalledWith(
|
|
279
|
+
'trust:appeal-relief:session:abc:ip:203.0.113.10',
|
|
280
|
+
'1',
|
|
281
|
+
{ ex: 600 }
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
await expect(
|
|
285
|
+
hasCachedIpBlockAppealRelief('session:abc', 'ip:203.0.113.10')
|
|
286
|
+
).resolves.toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('fails closed for appeal relief when Redis is unavailable', async () => {
|
|
290
|
+
mockRedisClient();
|
|
291
|
+
mocks.get.mockRejectedValue(new Error('redis down'));
|
|
292
|
+
|
|
293
|
+
const { hasCachedIpBlockAppealRelief } = await import('../edge-trust.js');
|
|
294
|
+
|
|
295
|
+
await expect(
|
|
296
|
+
hasCachedIpBlockAppealRelief('session:abc', 'ip:203.0.113.10')
|
|
297
|
+
).resolves.toBe(false);
|
|
298
|
+
});
|
|
258
299
|
});
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from '../upstash-rest';
|
|
22
22
|
|
|
23
23
|
const TRUST_CACHE_PREFIX = 'trust:mult';
|
|
24
|
+
const APPEAL_RELIEF_CACHE_PREFIX = 'trust:appeal-relief';
|
|
24
25
|
|
|
25
26
|
// Mirrors the DB CHECK constraint on trust_multiplier (> 0 AND <= 1000).
|
|
26
27
|
const MIN_TRUST_MULTIPLIER = 0.35;
|
|
@@ -44,6 +45,7 @@ export interface CachedTrustEntry {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
const DEFAULT_TRUST_CACHE_TTL_SECONDS = 3600; // 1 hour
|
|
48
|
+
const DEFAULT_APPEAL_RELIEF_TTL_SECONDS = 15 * 60; // 15 minutes
|
|
47
49
|
|
|
48
50
|
let trustRedisClient: UpstashRestRedisClient | null = null;
|
|
49
51
|
let trustRedisInitialized = false;
|
|
@@ -65,6 +67,13 @@ export function getTrustCacheTtlSeconds(): number {
|
|
|
65
67
|
);
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
export function getAppealReliefTtlSeconds(): number {
|
|
71
|
+
return parsePositiveIntEnv(
|
|
72
|
+
'RATE_LIMIT_APPEAL_RELIEF_TTL_SECONDS',
|
|
73
|
+
DEFAULT_APPEAL_RELIEF_TTL_SECONDS
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
async function getTrustRedisClient() {
|
|
69
78
|
if (trustRedisInitialized) return trustRedisClient;
|
|
70
79
|
|
|
@@ -86,6 +95,13 @@ export function buildTrustCacheKey(subjectKey: string): string {
|
|
|
86
95
|
return `${TRUST_CACHE_PREFIX}:${subjectKey}`;
|
|
87
96
|
}
|
|
88
97
|
|
|
98
|
+
export function buildIpBlockAppealReliefKey(
|
|
99
|
+
sessionKey: string,
|
|
100
|
+
ipKey: string
|
|
101
|
+
): string {
|
|
102
|
+
return `${APPEAL_RELIEF_CACHE_PREFIX}:${sessionKey}:${ipKey}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
89
105
|
function clampMultiplier(value: number): number {
|
|
90
106
|
if (!Number.isFinite(value) || value <= 0) {
|
|
91
107
|
return NEUTRAL_TRUST_MULTIPLIER;
|
|
@@ -410,3 +426,54 @@ export async function writeVerifiedSessionCacheForSubjects(
|
|
|
410
426
|
// Cache writes must never block the protected request path.
|
|
411
427
|
}
|
|
412
428
|
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Grants a short, session+IP-scoped bypass for the hard IP-block check. This is
|
|
432
|
+
* used only after an authenticated user passes Turnstile and submits an appeal;
|
|
433
|
+
* it does not clear the global IP block or elevate unrelated sessions.
|
|
434
|
+
*/
|
|
435
|
+
export async function setCachedIpBlockAppealRelief(
|
|
436
|
+
sessionKey: string,
|
|
437
|
+
ipKey: string,
|
|
438
|
+
ttlSeconds: number = getAppealReliefTtlSeconds()
|
|
439
|
+
): Promise<void> {
|
|
440
|
+
if (!sessionKey || !ipKey) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const redis = await getTrustRedisClient();
|
|
446
|
+
if (!redis) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
await redis.set(buildIpBlockAppealReliefKey(sessionKey, ipKey), '1', {
|
|
451
|
+
ex: ttlSeconds,
|
|
452
|
+
});
|
|
453
|
+
} catch {
|
|
454
|
+
// Cache writes must never block the appeal path.
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function hasCachedIpBlockAppealRelief(
|
|
459
|
+
sessionKey: string,
|
|
460
|
+
ipKey: string
|
|
461
|
+
): Promise<boolean> {
|
|
462
|
+
if (!sessionKey || !ipKey) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const redis = await getTrustRedisClient();
|
|
468
|
+
if (!redis) {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return Boolean(
|
|
473
|
+
await redis.get<string>(buildIpBlockAppealReliefKey(sessionKey, ipKey))
|
|
474
|
+
);
|
|
475
|
+
} catch {
|
|
476
|
+
// Fail closed for the hard-block bypass.
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
}
|
package/src/api-proxy-guard.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { extractIPFromRequest, isIPBlockedEdge } from './abuse-protection/edge';
|
|
|
5
5
|
import {
|
|
6
6
|
type CachedTrustEntry,
|
|
7
7
|
getCachedTrustEntries,
|
|
8
|
+
hasCachedIpBlockAppealRelief,
|
|
8
9
|
} from './abuse-protection/edge-trust';
|
|
9
10
|
import { DEV_MODE, MAX_PAYLOAD_SIZE } from './constants';
|
|
10
11
|
import { validateRequestEmojiLimit } from './request-emoji-limit';
|
|
@@ -256,6 +257,20 @@ const OTP_VERIFY_RATE_LIMITS: RateLimitProfile = {
|
|
|
256
257
|
],
|
|
257
258
|
};
|
|
258
259
|
|
|
260
|
+
const RATE_LIMIT_APPEAL_RATE_LIMITS: RateLimitProfile = {
|
|
261
|
+
get: NO_READ_RATE_LIMITS,
|
|
262
|
+
mutate: [
|
|
263
|
+
createConfig(
|
|
264
|
+
'minute',
|
|
265
|
+
'1 m',
|
|
266
|
+
1,
|
|
267
|
+
'API_PROXY_RATE_LIMIT_APPEAL_LIMIT_MINUTE'
|
|
268
|
+
),
|
|
269
|
+
createConfig('hour', '1 h', 3, 'API_PROXY_RATE_LIMIT_APPEAL_LIMIT_HOUR'),
|
|
270
|
+
createConfig('day', '1 d', 10, 'API_PROXY_RATE_LIMIT_APPEAL_LIMIT_DAY'),
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
|
|
259
274
|
const CRON_RATE_LIMITS: RateLimitProfile = {
|
|
260
275
|
get: [
|
|
261
276
|
createConfig('minute', '1 m', 10, 'API_PROXY_CRON_READ_LIMIT_MINUTE'),
|
|
@@ -429,7 +444,19 @@ function isWorkspaceDashboardRead(req: NextRequest) {
|
|
|
429
444
|
);
|
|
430
445
|
}
|
|
431
446
|
|
|
447
|
+
function isRateLimitAppealSubmission(req: NextRequest) {
|
|
448
|
+
return (
|
|
449
|
+
req.method === 'POST' &&
|
|
450
|
+
/^\/api\/v1\/rate-limit-appeals\/?$/u.test(req.nextUrl.pathname)
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
432
454
|
const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
|
|
455
|
+
{
|
|
456
|
+
key: 'rate-limit-appeal',
|
|
457
|
+
matches: isRateLimitAppealSubmission,
|
|
458
|
+
rateLimits: RATE_LIMIT_APPEAL_RATE_LIMITS,
|
|
459
|
+
},
|
|
433
460
|
{
|
|
434
461
|
key: 'cron',
|
|
435
462
|
matches: (req) =>
|
|
@@ -915,6 +942,13 @@ async function hashStableSubjectEdge(value: string): Promise<string> {
|
|
|
915
942
|
.slice(0, 24);
|
|
916
943
|
}
|
|
917
944
|
|
|
945
|
+
export async function buildProxySessionSubjectKey(
|
|
946
|
+
cookieName: string,
|
|
947
|
+
cookieValue: string
|
|
948
|
+
): Promise<string> {
|
|
949
|
+
return `session:${await hashStableSubjectEdge(`${cookieName}:${cookieValue}`)}`;
|
|
950
|
+
}
|
|
951
|
+
|
|
918
952
|
function parseCookieHeaderEdge(
|
|
919
953
|
cookieHeader: string | null
|
|
920
954
|
): Array<{ name: string; value: string }> {
|
|
@@ -954,6 +988,19 @@ function getSessionAuthCookie(
|
|
|
954
988
|
);
|
|
955
989
|
}
|
|
956
990
|
|
|
991
|
+
export async function getProxySessionSubjectKeyFromCookieHeader(
|
|
992
|
+
cookieHeader: string | null
|
|
993
|
+
): Promise<string | null> {
|
|
994
|
+
const cookie =
|
|
995
|
+
parseCookieHeaderEdge(cookieHeader).find(
|
|
996
|
+
(entry) =>
|
|
997
|
+
entry.name === 'tuturuuu_app_session' ||
|
|
998
|
+
GENERIC_SUPABASE_AUTH_COOKIE_NAME_PATTERN.test(entry.name)
|
|
999
|
+
) ?? null;
|
|
1000
|
+
|
|
1001
|
+
return cookie ? buildProxySessionSubjectKey(cookie.name, cookie.value) : null;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
957
1004
|
/**
|
|
958
1005
|
* Mirrors `reputation.ts getCidrSubjectKey` so the edge cidr subject key matches
|
|
959
1006
|
* the DB-side trusted-location key.
|
|
@@ -1013,7 +1060,7 @@ async function resolveProxyIdentity(
|
|
|
1013
1060
|
const normalizedIp = ip && ip !== 'unknown' ? ip : null;
|
|
1014
1061
|
const sessionCookie = getSessionAuthCookie(req);
|
|
1015
1062
|
const sessionKey = sessionCookie
|
|
1016
|
-
?
|
|
1063
|
+
? await buildProxySessionSubjectKey(sessionCookie.name, sessionCookie.value)
|
|
1017
1064
|
: null;
|
|
1018
1065
|
|
|
1019
1066
|
return {
|
|
@@ -1246,22 +1293,36 @@ export async function guardApiProxyRequest(
|
|
|
1246
1293
|
const callerClass = getCallerClass(req);
|
|
1247
1294
|
const ip = extractIPFromRequest(req.headers);
|
|
1248
1295
|
const identity = await resolveProxyIdentity(req, ip);
|
|
1296
|
+
const routePolicy = getRoutePolicy(req, routePolicies);
|
|
1249
1297
|
|
|
1250
1298
|
if (ip !== 'unknown') {
|
|
1251
1299
|
const blockInfo = await isIPBlockedEdge(ip);
|
|
1252
1300
|
if (blockInfo) {
|
|
1253
|
-
const
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1301
|
+
const hasTemporaryRelief =
|
|
1302
|
+
routePolicy.key === 'rate-limit-appeal' ||
|
|
1303
|
+
(identity.sessionKey && identity.ipKey
|
|
1304
|
+
? await hasCachedIpBlockAppealRelief(
|
|
1305
|
+
identity.sessionKey,
|
|
1306
|
+
identity.ipKey
|
|
1307
|
+
)
|
|
1308
|
+
: false);
|
|
1309
|
+
|
|
1310
|
+
if (hasTemporaryRelief) {
|
|
1311
|
+
// Keep processing: appeal submissions and matching temporary relief
|
|
1312
|
+
// still go through the route limiter below.
|
|
1313
|
+
} else {
|
|
1314
|
+
const retryAfter = Math.max(
|
|
1315
|
+
1,
|
|
1316
|
+
Math.ceil((blockInfo.expiresAt.getTime() - Date.now()) / 1000)
|
|
1317
|
+
);
|
|
1318
|
+
|
|
1319
|
+
return buildRateLimitResponse(429, retryAfter, {
|
|
1320
|
+
'X-Proxy-Block-Reason': 'ip-already-blocked',
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1261
1323
|
}
|
|
1262
1324
|
}
|
|
1263
1325
|
|
|
1264
|
-
const routePolicy = getRoutePolicy(req, routePolicies);
|
|
1265
1326
|
const isRead = req.method === 'GET' || req.method === 'HEAD';
|
|
1266
1327
|
|
|
1267
1328
|
// Resolve server-verified session keying for reads and mutations, but apply
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
|
|
8
8
|
describe('platform release metadata', () => {
|
|
9
9
|
it('uses the centralized shared browser app version', () => {
|
|
10
|
-
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.15.0'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('normalizes generated metadata and derives a short hash', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.
|
|
3
|
+
export const TUTURUUU_PLATFORM_VERSION = '0.15.0'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
6
|
builtAt?: string | null;
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import type { JSONContent } from '@tiptap/core';
|
|
3
|
+
import { Node as ProseMirrorNode } from 'prosemirror-model';
|
|
4
|
+
import {
|
|
5
|
+
hasMeaningfulTaskDescriptionContent,
|
|
6
|
+
parseTaskDescriptionContent,
|
|
7
|
+
taskDescriptionSchema,
|
|
8
|
+
} from './task-description-content';
|
|
9
|
+
import { getDescriptionText } from './text-helper';
|
|
10
|
+
import {
|
|
11
|
+
convertJsonContentToYjsState,
|
|
12
|
+
convertYjsStateToJsonContent,
|
|
13
|
+
} from './yjs-helper';
|
|
14
|
+
|
|
15
|
+
export type TaskDescriptionInputFormat = 'json' | 'markdown' | 'text';
|
|
16
|
+
export type TaskDescriptionYjsFormat = 'bytes-json' | 'yjs-base64';
|
|
17
|
+
|
|
18
|
+
export interface TaskDescriptionPayload {
|
|
19
|
+
content: JSONContent;
|
|
20
|
+
description: string | null;
|
|
21
|
+
description_yjs_state: number[] | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type InlineMark = NonNullable<JSONContent['marks']>[number];
|
|
25
|
+
type ListKind = 'bulletList' | 'orderedList' | 'taskList';
|
|
26
|
+
|
|
27
|
+
const MARK_PATTERNS = [
|
|
28
|
+
{ mark: 'code', pattern: /`([^`]+)`/u },
|
|
29
|
+
{ mark: 'bold', pattern: /\*\*([^*]+)\*\*/u },
|
|
30
|
+
{ mark: 'strike', pattern: /~~(.+?)~~/u },
|
|
31
|
+
{ mark: 'italic', pattern: /\*([^*\n]+)\*/u },
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
function textNode(text: string, marks?: JSONContent['marks']): JSONContent[] {
|
|
35
|
+
if (!text) return [];
|
|
36
|
+
return marks?.length
|
|
37
|
+
? [{ marks, text, type: 'text' }]
|
|
38
|
+
: [{ text, type: 'text' }];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withMark(type: InlineMark['type'], attrs?: InlineMark['attrs']) {
|
|
42
|
+
const mark: InlineMark = { type };
|
|
43
|
+
if (attrs && Object.keys(attrs).length > 0) {
|
|
44
|
+
mark.attrs = attrs;
|
|
45
|
+
}
|
|
46
|
+
return mark;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function findEarliestInlineToken(text: string):
|
|
50
|
+
| {
|
|
51
|
+
attrs?: Record<string, unknown>;
|
|
52
|
+
before: string;
|
|
53
|
+
inner: string;
|
|
54
|
+
mark: InlineMark['type'];
|
|
55
|
+
rest: string;
|
|
56
|
+
}
|
|
57
|
+
| undefined {
|
|
58
|
+
const linkMatch = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/u.exec(text);
|
|
59
|
+
let best:
|
|
60
|
+
| {
|
|
61
|
+
attrs?: Record<string, unknown>;
|
|
62
|
+
before: string;
|
|
63
|
+
inner: string;
|
|
64
|
+
index: number;
|
|
65
|
+
mark: InlineMark['type'];
|
|
66
|
+
rest: string;
|
|
67
|
+
}
|
|
68
|
+
| undefined;
|
|
69
|
+
|
|
70
|
+
if (linkMatch?.index !== undefined) {
|
|
71
|
+
best = {
|
|
72
|
+
attrs: {
|
|
73
|
+
href: linkMatch[2],
|
|
74
|
+
rel: null,
|
|
75
|
+
target: null,
|
|
76
|
+
title: linkMatch[3] ?? null,
|
|
77
|
+
},
|
|
78
|
+
before: text.slice(0, linkMatch.index),
|
|
79
|
+
index: linkMatch.index,
|
|
80
|
+
inner: linkMatch[1] ?? '',
|
|
81
|
+
mark: 'link',
|
|
82
|
+
rest: text.slice(linkMatch.index + linkMatch[0].length),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const { mark, pattern } of MARK_PATTERNS) {
|
|
87
|
+
const match = pattern.exec(text);
|
|
88
|
+
if (!match?.[0] || match.index === undefined) continue;
|
|
89
|
+
if (best && match.index >= best.index) continue;
|
|
90
|
+
|
|
91
|
+
best = {
|
|
92
|
+
before: text.slice(0, match.index),
|
|
93
|
+
index: match.index,
|
|
94
|
+
inner: match[1] ?? '',
|
|
95
|
+
mark,
|
|
96
|
+
rest: text.slice(match.index + match[0].length),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return best;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseInlineMarkdown(
|
|
104
|
+
text: string,
|
|
105
|
+
marks: JSONContent['marks'] = []
|
|
106
|
+
): JSONContent[] {
|
|
107
|
+
const token = findEarliestInlineToken(text);
|
|
108
|
+
if (!token) return textNode(text, marks);
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
...textNode(token.before, marks),
|
|
112
|
+
...parseInlineMarkdown(token.inner, [
|
|
113
|
+
...marks,
|
|
114
|
+
withMark(token.mark, token.attrs),
|
|
115
|
+
]),
|
|
116
|
+
...parseInlineMarkdown(token.rest, marks),
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function paragraphFromText(text: string, markdown = false): JSONContent {
|
|
121
|
+
const lines = text.split('\n');
|
|
122
|
+
const content: JSONContent[] = [];
|
|
123
|
+
|
|
124
|
+
for (const [index, line] of lines.entries()) {
|
|
125
|
+
if (index > 0) {
|
|
126
|
+
content.push({ type: 'hardBreak' });
|
|
127
|
+
}
|
|
128
|
+
content.push(...(markdown ? parseInlineMarkdown(line) : textNode(line)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return content.length > 0
|
|
132
|
+
? { content, type: 'paragraph' }
|
|
133
|
+
: { type: 'paragraph' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function codeBlockFromLines(lines: string[], language?: string): JSONContent {
|
|
137
|
+
return {
|
|
138
|
+
attrs: { language: language || null },
|
|
139
|
+
content:
|
|
140
|
+
lines.length > 0 ? [{ text: lines.join('\n'), type: 'text' }] : undefined,
|
|
141
|
+
type: 'codeBlock',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isHorizontalRule(line: string) {
|
|
146
|
+
return /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/u.test(line);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function getListLine(line: string):
|
|
150
|
+
| {
|
|
151
|
+
checked?: boolean;
|
|
152
|
+
kind: ListKind;
|
|
153
|
+
start?: number;
|
|
154
|
+
text: string;
|
|
155
|
+
}
|
|
156
|
+
| undefined {
|
|
157
|
+
const taskMatch = /^\s*[-*+]\s+\[([ xX])\]\s+(.+)$/u.exec(line);
|
|
158
|
+
if (taskMatch) {
|
|
159
|
+
return {
|
|
160
|
+
checked: taskMatch[1]?.toLowerCase() === 'x',
|
|
161
|
+
kind: 'taskList',
|
|
162
|
+
text: taskMatch[2] ?? '',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const bulletMatch = /^\s*[-*+]\s+(.+)$/u.exec(line);
|
|
167
|
+
if (bulletMatch) {
|
|
168
|
+
return { kind: 'bulletList', text: bulletMatch[1] ?? '' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const orderedMatch = /^\s*(\d+)[.)]\s+(.+)$/u.exec(line);
|
|
172
|
+
if (orderedMatch) {
|
|
173
|
+
return {
|
|
174
|
+
kind: 'orderedList',
|
|
175
|
+
start: Number.parseInt(orderedMatch[1] ?? '1', 10),
|
|
176
|
+
text: orderedMatch[2] ?? '',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isBlockStart(line: string) {
|
|
182
|
+
return (
|
|
183
|
+
/^```/u.test(line) ||
|
|
184
|
+
/^#{1,6}\s+/u.test(line) ||
|
|
185
|
+
/^>\s?/u.test(line) ||
|
|
186
|
+
isHorizontalRule(line) ||
|
|
187
|
+
Boolean(getListLine(line))
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function createListBlock(
|
|
192
|
+
kind: ListKind,
|
|
193
|
+
entries: Array<{ checked?: boolean; text: string }>,
|
|
194
|
+
start?: number
|
|
195
|
+
): JSONContent {
|
|
196
|
+
if (kind === 'taskList') {
|
|
197
|
+
return {
|
|
198
|
+
content: entries.map((entry) => ({
|
|
199
|
+
attrs: { checked: entry.checked === true },
|
|
200
|
+
content: [paragraphFromText(entry.text, true)],
|
|
201
|
+
type: 'taskItem',
|
|
202
|
+
})),
|
|
203
|
+
type: 'taskList',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
attrs: kind === 'orderedList' ? { start: start ?? 1 } : undefined,
|
|
209
|
+
content: entries.map((entry) => ({
|
|
210
|
+
content: [paragraphFromText(entry.text, true)],
|
|
211
|
+
type: 'listItem',
|
|
212
|
+
})),
|
|
213
|
+
type: kind,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseMarkdownBlocks(markdown: string): JSONContent[] {
|
|
218
|
+
const lines = markdown.replace(/\r\n?/gu, '\n').split('\n');
|
|
219
|
+
const blocks: JSONContent[] = [];
|
|
220
|
+
let index = 0;
|
|
221
|
+
|
|
222
|
+
while (index < lines.length) {
|
|
223
|
+
const line = lines[index] ?? '';
|
|
224
|
+
|
|
225
|
+
if (!line.trim()) {
|
|
226
|
+
index += 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const fence = /^```([^\s`]*)\s*$/u.exec(line);
|
|
231
|
+
if (fence) {
|
|
232
|
+
const codeLines: string[] = [];
|
|
233
|
+
index += 1;
|
|
234
|
+
while (index < lines.length && !/^```\s*$/u.test(lines[index] ?? '')) {
|
|
235
|
+
codeLines.push(lines[index] ?? '');
|
|
236
|
+
index += 1;
|
|
237
|
+
}
|
|
238
|
+
if (index < lines.length) index += 1;
|
|
239
|
+
blocks.push(codeBlockFromLines(codeLines, fence[1]));
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const heading = /^(#{1,6})\s+(.+)$/u.exec(line);
|
|
244
|
+
if (heading) {
|
|
245
|
+
blocks.push({
|
|
246
|
+
attrs: { level: heading[1]?.length ?? 1, textAlign: null },
|
|
247
|
+
content: parseInlineMarkdown(heading[2] ?? ''),
|
|
248
|
+
type: 'heading',
|
|
249
|
+
});
|
|
250
|
+
index += 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (isHorizontalRule(line)) {
|
|
255
|
+
blocks.push({ type: 'horizontalRule' });
|
|
256
|
+
index += 1;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (/^>\s?/u.test(line)) {
|
|
261
|
+
const quoted: string[] = [];
|
|
262
|
+
while (index < lines.length && /^>\s?/u.test(lines[index] ?? '')) {
|
|
263
|
+
quoted.push((lines[index] ?? '').replace(/^>\s?/u, ''));
|
|
264
|
+
index += 1;
|
|
265
|
+
}
|
|
266
|
+
const content = parseMarkdownBlocks(quoted.join('\n'));
|
|
267
|
+
blocks.push({
|
|
268
|
+
content: content.length > 0 ? content : [{ type: 'paragraph' }],
|
|
269
|
+
type: 'blockquote',
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const listLine = getListLine(line);
|
|
275
|
+
if (listLine) {
|
|
276
|
+
const entries: Array<{ checked?: boolean; text: string }> = [];
|
|
277
|
+
const listKind = listLine.kind;
|
|
278
|
+
const start = listLine.start;
|
|
279
|
+
|
|
280
|
+
while (index < lines.length) {
|
|
281
|
+
const item = getListLine(lines[index] ?? '');
|
|
282
|
+
if (!item || item.kind !== listKind) break;
|
|
283
|
+
entries.push({ checked: item.checked, text: item.text });
|
|
284
|
+
index += 1;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
blocks.push(createListBlock(listKind, entries, start));
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const paragraphLines = [line];
|
|
292
|
+
index += 1;
|
|
293
|
+
while (
|
|
294
|
+
index < lines.length &&
|
|
295
|
+
(lines[index] ?? '').trim() &&
|
|
296
|
+
!isBlockStart(lines[index] ?? '')
|
|
297
|
+
) {
|
|
298
|
+
paragraphLines.push(lines[index] ?? '');
|
|
299
|
+
index += 1;
|
|
300
|
+
}
|
|
301
|
+
blocks.push(paragraphFromText(paragraphLines.join('\n'), true));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return blocks;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function createPlainTextTaskDescriptionContent(text: string) {
|
|
308
|
+
const normalizedText = text.replace(/\r\n?/gu, '\n');
|
|
309
|
+
const paragraphs = normalizedText.split(/\n{2,}/u);
|
|
310
|
+
const content = paragraphs.map((paragraph) =>
|
|
311
|
+
paragraphFromText(paragraph, false)
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
content: content.length > 0 ? content : [{ type: 'paragraph' }],
|
|
316
|
+
type: 'doc',
|
|
317
|
+
} satisfies JSONContent;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function parseMarkdownTaskDescriptionContent(markdown: string) {
|
|
321
|
+
const content = parseMarkdownBlocks(markdown);
|
|
322
|
+
return {
|
|
323
|
+
content: content.length > 0 ? content : [{ type: 'paragraph' }],
|
|
324
|
+
type: 'doc',
|
|
325
|
+
} satisfies JSONContent;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function normalizeTaskDescriptionContent(
|
|
329
|
+
content: JSONContent
|
|
330
|
+
): JSONContent {
|
|
331
|
+
const transformed = parseTaskDescriptionContent(JSON.stringify(content));
|
|
332
|
+
return ProseMirrorNode.fromJSON(
|
|
333
|
+
taskDescriptionSchema,
|
|
334
|
+
transformed
|
|
335
|
+
).toJSON() as JSONContent;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function parseTaskDescriptionJson(input: string): JSONContent {
|
|
339
|
+
const parsed = JSON.parse(input) as unknown;
|
|
340
|
+
if (
|
|
341
|
+
!parsed ||
|
|
342
|
+
typeof parsed !== 'object' ||
|
|
343
|
+
Array.isArray(parsed) ||
|
|
344
|
+
(parsed as JSONContent).type !== 'doc'
|
|
345
|
+
) {
|
|
346
|
+
throw new Error('Task description JSON must be a TipTap doc object');
|
|
347
|
+
}
|
|
348
|
+
return normalizeTaskDescriptionContent(parsed as JSONContent);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function parseTaskDescriptionInput(
|
|
352
|
+
input: string,
|
|
353
|
+
format: TaskDescriptionInputFormat = 'text'
|
|
354
|
+
): JSONContent {
|
|
355
|
+
if (format === 'json') {
|
|
356
|
+
return parseTaskDescriptionJson(input);
|
|
357
|
+
}
|
|
358
|
+
if (format === 'markdown') {
|
|
359
|
+
return normalizeTaskDescriptionContent(
|
|
360
|
+
parseMarkdownTaskDescriptionContent(input)
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
return normalizeTaskDescriptionContent(
|
|
364
|
+
createPlainTextTaskDescriptionContent(input)
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function parsePersistedTaskDescriptionContent({
|
|
369
|
+
description,
|
|
370
|
+
description_yjs_state,
|
|
371
|
+
}: {
|
|
372
|
+
description?: string | null;
|
|
373
|
+
description_yjs_state?: number[] | null;
|
|
374
|
+
}): JSONContent {
|
|
375
|
+
if (description_yjs_state && description_yjs_state.length > 0) {
|
|
376
|
+
return decodeTaskDescriptionYjsState(description_yjs_state);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
return normalizeTaskDescriptionContent(
|
|
381
|
+
parseTaskDescriptionContent(description ?? '')
|
|
382
|
+
);
|
|
383
|
+
} catch {
|
|
384
|
+
return normalizeTaskDescriptionContent(
|
|
385
|
+
createPlainTextTaskDescriptionContent(
|
|
386
|
+
getDescriptionText(description ?? '').trim()
|
|
387
|
+
)
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function encodeTaskDescriptionYjsState(content: JSONContent): number[] {
|
|
393
|
+
return Array.from(
|
|
394
|
+
convertJsonContentToYjsState(
|
|
395
|
+
normalizeTaskDescriptionContent(content),
|
|
396
|
+
taskDescriptionSchema
|
|
397
|
+
)
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function decodeTaskDescriptionYjsState(
|
|
402
|
+
yjsState: number[] | Uint8Array
|
|
403
|
+
): JSONContent {
|
|
404
|
+
return convertYjsStateToJsonContent(
|
|
405
|
+
yjsState instanceof Uint8Array ? yjsState : Uint8Array.from(yjsState),
|
|
406
|
+
taskDescriptionSchema
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function taskDescriptionYjsStateToBase64(
|
|
411
|
+
yjsState: number[] | Uint8Array | null | undefined
|
|
412
|
+
): string | null {
|
|
413
|
+
if (yjsState == null) return null;
|
|
414
|
+
const bytes =
|
|
415
|
+
yjsState instanceof Uint8Array ? yjsState : Uint8Array.from(yjsState);
|
|
416
|
+
return Buffer.from(bytes).toString('base64');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function taskDescriptionYjsStateFromBase64(input: string): number[] {
|
|
420
|
+
return Array.from(Buffer.from(input.trim(), 'base64'));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function taskDescriptionYjsStateFromBytesJson(input: string): number[] {
|
|
424
|
+
const parsed = JSON.parse(input) as unknown;
|
|
425
|
+
if (
|
|
426
|
+
!Array.isArray(parsed) ||
|
|
427
|
+
parsed.some((value) => !Number.isInteger(value) || value < 0 || value > 255)
|
|
428
|
+
) {
|
|
429
|
+
throw new Error('Yjs bytes JSON must be an array of byte values');
|
|
430
|
+
}
|
|
431
|
+
return parsed as number[];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function taskDescriptionPlainText(content: JSONContent): string {
|
|
435
|
+
return getDescriptionText(normalizeTaskDescriptionContent(content));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function createTaskDescriptionPayload(
|
|
439
|
+
input: string,
|
|
440
|
+
format: TaskDescriptionInputFormat = 'text'
|
|
441
|
+
): TaskDescriptionPayload {
|
|
442
|
+
const content = parseTaskDescriptionInput(input, format);
|
|
443
|
+
|
|
444
|
+
if (!hasMeaningfulTaskDescriptionContent(content)) {
|
|
445
|
+
return {
|
|
446
|
+
content,
|
|
447
|
+
description: null,
|
|
448
|
+
description_yjs_state: null,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
content,
|
|
454
|
+
description: JSON.stringify(content),
|
|
455
|
+
description_yjs_state: encodeTaskDescriptionYjsState(content),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import type { JSONContent } from '@tiptap/core';
|
|
2
|
+
import {
|
|
3
|
+
decodeTaskDescriptionYjsState,
|
|
4
|
+
encodeTaskDescriptionYjsState,
|
|
5
|
+
parsePersistedTaskDescriptionContent,
|
|
6
|
+
parseTaskDescriptionInput,
|
|
7
|
+
} from './task-description-codec';
|
|
2
8
|
import {
|
|
3
9
|
hasMeaningfulTaskDescriptionContent,
|
|
4
10
|
isValidTaskDescriptionContent,
|
|
5
|
-
parseTaskDescriptionContent,
|
|
6
11
|
taskDescriptionSchema,
|
|
7
12
|
} from './task-description-content';
|
|
8
13
|
import { getDescriptionText } from './text-helper';
|
|
9
|
-
import {
|
|
10
|
-
convertJsonContentToYjsState,
|
|
11
|
-
convertYjsStateToJsonContent,
|
|
12
|
-
} from './yjs-helper';
|
|
13
14
|
|
|
14
15
|
export { isValidTaskDescriptionContent, taskDescriptionSchema };
|
|
15
16
|
|
|
@@ -21,10 +22,7 @@ export function isValidTaskDescriptionYjsState(
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
try {
|
|
24
|
-
|
|
25
|
-
Uint8Array.from(yjsState),
|
|
26
|
-
taskDescriptionSchema
|
|
27
|
-
);
|
|
25
|
+
decodeTaskDescriptionYjsState(yjsState);
|
|
28
26
|
return true;
|
|
29
27
|
} catch {
|
|
30
28
|
return false;
|
|
@@ -32,11 +30,11 @@ export function isValidTaskDescriptionYjsState(
|
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
function encodeYjsState(content: JSONContent): number[] | null {
|
|
35
|
-
const encoded =
|
|
33
|
+
const encoded = encodeTaskDescriptionYjsState(content);
|
|
36
34
|
if (encoded.length === 0) {
|
|
37
35
|
return null;
|
|
38
36
|
}
|
|
39
|
-
return
|
|
37
|
+
return encoded;
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
/**
|
|
@@ -51,7 +49,9 @@ export function deriveTaskDescriptionYjsState(
|
|
|
51
49
|
return null;
|
|
52
50
|
}
|
|
53
51
|
|
|
54
|
-
const content =
|
|
52
|
+
const content = parsePersistedTaskDescriptionContent({
|
|
53
|
+
description: normalizedDescription,
|
|
54
|
+
});
|
|
55
55
|
if (!hasMeaningfulTaskDescriptionContent(content)) {
|
|
56
56
|
return null;
|
|
57
57
|
}
|
|
@@ -65,15 +65,7 @@ export function deriveTaskDescriptionYjsState(
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
try {
|
|
68
|
-
return encodeYjsState(
|
|
69
|
-
type: 'doc',
|
|
70
|
-
content: [
|
|
71
|
-
{
|
|
72
|
-
type: 'paragraph',
|
|
73
|
-
content: [{ type: 'text', text: fallbackText }],
|
|
74
|
-
},
|
|
75
|
-
],
|
|
76
|
-
});
|
|
68
|
+
return encodeYjsState(parseTaskDescriptionInput(fallbackText, 'text'));
|
|
77
69
|
} catch {
|
|
78
70
|
return null;
|
|
79
71
|
}
|