@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 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.9.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.10.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.11.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
+ }
@@ -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
- ? `session:${await hashStableSubjectEdge(`${sessionCookie.name}:${sessionCookie.value}`)}`
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 retryAfter = Math.max(
1254
- 1,
1255
- Math.ceil((blockInfo.expiresAt.getTime() - Date.now()) / 1000)
1256
- );
1257
-
1258
- return buildRateLimitResponse(429, retryAfter, {
1259
- 'X-Proxy-Block-Reason': 'ip-already-blocked',
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.13.0'); // x-release-please-version
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', () => {
@@ -1,6 +1,6 @@
1
1
  import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
2
2
 
3
- export const TUTURUUU_PLATFORM_VERSION = '0.13.0'; // x-release-please-version
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
- convertYjsStateToJsonContent(
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 = convertJsonContentToYjsState(content, taskDescriptionSchema);
33
+ const encoded = encodeTaskDescriptionYjsState(content);
36
34
  if (encoded.length === 0) {
37
35
  return null;
38
36
  }
39
- return Array.from(encoded);
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 = parseTaskDescriptionContent(normalizedDescription);
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
  }