@tuturuuu/utils 0.10.0 → 0.12.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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0](https://github.com/tutur3u/platform/compare/utils-v0.11.0...utils-v0.12.0) (2026-07-02)
4
+
5
+
6
+ ### Features
7
+
8
+ * **infrastructure:** add abuse protection controls ([0e09692](https://github.com/tutur3u/platform/commit/0e09692a553cb10fbbb521e921951c2a6863281d))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **ci:** keep cache components enabled in production ([75e6e2b](https://github.com/tutur3u/platform/commit/75e6e2bbbf6fa432bc496ee95b75ade5f8847f9f))
14
+ * **cli:** preserve markdown tables in task descriptions ([9faf8ba](https://github.com/tutur3u/platform/commit/9faf8ba44b92565a9dfc2949c496654fc1fea9b3))
15
+ * **infrastructure:** default docker web to native builds ([7d89f00](https://github.com/tutur3u/platform/commit/7d89f004f75ab12eddc3cb92df5a1e6a7255fe0d))
16
+ * **security:** limit auth recovery code attempts ([72d454c](https://github.com/tutur3u/platform/commit/72d454c507b0d4b7e69c7b38ad2099dbb83193b2))
17
+
18
+ ## [0.11.0](https://github.com/tutur3u/platform/compare/utils-v0.10.0...utils-v0.11.0) (2026-06-29)
19
+
20
+
21
+ ### Features
22
+
23
+ * **infrastructure:** add rate-limit appeals ([f1e50e9](https://github.com/tutur3u/platform/commit/f1e50e9fd805dca6c80da8daab60ed24199f24ab))
24
+
3
25
  ## [0.10.0](https://github.com/tutur3u/platform/compare/utils-v0.9.0...utils-v0.10.0) (2026-06-26)
4
26
 
5
27
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tuturuuu/utils",
3
3
  "license": "MIT",
4
- "version": "0.10.0",
4
+ "version": "0.12.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/tutur3u/platform",
@@ -19,12 +19,12 @@
19
19
  "@dnd-kit/core": "^6.3.1",
20
20
  "@noble/ciphers": "^2.2.0",
21
21
  "@noble/hashes": "^2.2.0",
22
- "@tanstack/react-query": "^5.101.1",
22
+ "@tanstack/react-query": "^5.101.2",
23
23
  "@tiptap/core": "3.27.1",
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.11.0",
27
+ "@tuturuuu/internal-api": "0.13.0",
28
28
  "@tuturuuu/supabase": "0.4.0",
29
29
  "@upstash/ratelimit": "^2.0.8",
30
30
  "@upstash/redis": "^1.38.0",
@@ -34,7 +34,7 @@
34
34
  "diff": "^9.0.0",
35
35
  "effect": "^3.21.4",
36
36
  "moment": "^2.30.1",
37
- "next": "^16.2.9",
37
+ "next": "^16.3.0-preview.5",
38
38
  "prosemirror-model": "^1.25.9",
39
39
  "react": "^19.2.7",
40
40
  "react-dom": "^19.2.7",
@@ -45,14 +45,14 @@
45
45
  "zod": "^4.4.3"
46
46
  },
47
47
  "devDependencies": {
48
- "@tuturuuu/types": "0.12.0",
48
+ "@tuturuuu/types": "0.13.1",
49
49
  "@tuturuuu/typescript-config": "0.1.1",
50
50
  "@types/diff": "^8.0.0",
51
- "@types/node": "^26.0.0",
51
+ "@types/node": "^26.0.1",
52
52
  "@types/react": "^19.2.17",
53
53
  "@vitejs/plugin-react": "^6.0.3",
54
54
  "typescript": "7.0.1-rc",
55
- "vite": "^8.1.0",
55
+ "vite": "^8.1.2",
56
56
  "vitest": "^4.1.9"
57
57
  },
58
58
  "exports": {
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
8
8
  redis: vi.fn(),
9
9
  extractIp: vi.fn(),
10
10
  isBlocked: vi.fn(),
11
+ readAbuseProtectionControls: vi.fn(),
11
12
  validateEmoji: vi.fn(),
12
13
  getTrustEntries: vi.fn(),
13
14
  hasAppealRelief: vi.fn(),
@@ -50,6 +51,7 @@ vi.mock('@upstash/redis', () => ({
50
51
  vi.mock('../abuse-protection/edge', () => ({
51
52
  extractIPFromRequest: (headers: Headers) => mocks.extractIp(headers),
52
53
  isIPBlockedEdge: (ip: string) => mocks.isBlocked(ip),
54
+ readEdgeAbuseProtectionControls: () => mocks.readAbuseProtectionControls(),
53
55
  }));
54
56
 
55
57
  vi.mock('../abuse-protection/edge-trust', () => ({
@@ -105,12 +107,19 @@ describe('guardApiProxyRequest', () => {
105
107
  mocks.redis.mockReset();
106
108
  mocks.extractIp.mockReset();
107
109
  mocks.isBlocked.mockReset();
110
+ mocks.readAbuseProtectionControls.mockReset();
108
111
  mocks.validateEmoji.mockReset();
109
112
  mocks.getTrustEntries.mockReset();
110
113
  mocks.hasAppealRelief.mockReset();
111
114
  // Default: no cached trust (untrusted). Tests opt in by overriding.
112
115
  mocks.getTrustEntries.mockResolvedValue(new Map<string, { m: number }>());
113
116
  mocks.hasAppealRelief.mockResolvedValue(false);
117
+ mocks.readAbuseProtectionControls.mockResolvedValue({
118
+ ipBlockingEnabled: true,
119
+ rateLimitsEnabled: true,
120
+ updatedAt: null,
121
+ updatedBy: null,
122
+ });
114
123
  });
115
124
 
116
125
  afterEach(() => {
@@ -177,6 +186,98 @@ describe('guardApiProxyRequest', () => {
177
186
  expect(mocks.limit).not.toHaveBeenCalled();
178
187
  });
179
188
 
189
+ it('allows blocked IP traffic when IP blocking is disabled', async () => {
190
+ vi.stubEnv('NODE_ENV', 'production');
191
+ mocks.extractIp.mockReturnValue('1.2.3.4');
192
+ mocks.isBlocked.mockResolvedValue({
193
+ expiresAt: new Date(Date.now() + 30_000),
194
+ reason: 'abuse',
195
+ blockLevel: 'temporary',
196
+ });
197
+ mocks.readAbuseProtectionControls.mockResolvedValue({
198
+ ipBlockingEnabled: false,
199
+ rateLimitsEnabled: true,
200
+ updatedAt: null,
201
+ updatedBy: null,
202
+ });
203
+ mocks.validateEmoji.mockResolvedValue(null);
204
+
205
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
206
+ await import('../api-proxy-guard.js');
207
+ clearApiProxyGuardLimiterCache();
208
+
209
+ const response = await guardApiProxyRequest(
210
+ makeRequest('/api/test', 'POST'),
211
+ {
212
+ additionalRoutePolicies: [
213
+ {
214
+ key: 'ip-block-toggle-test',
215
+ matches: () => true,
216
+ rateLimits: { get: [], mutate: [] },
217
+ },
218
+ ],
219
+ prefixBase: 'proxy:test:api',
220
+ }
221
+ );
222
+
223
+ expect(response).toBeNull();
224
+ expect(mocks.isBlocked).not.toHaveBeenCalled();
225
+ expect(mocks.limit).not.toHaveBeenCalled();
226
+ expect(mocks.validateEmoji).toHaveBeenCalled();
227
+ });
228
+
229
+ it('skips proxy route buckets when rate limits are disabled', async () => {
230
+ vi.stubEnv('NODE_ENV', 'production');
231
+ mocks.extractIp.mockReturnValue('1.2.3.4');
232
+ mocks.isBlocked.mockResolvedValue(null);
233
+ mocks.readAbuseProtectionControls.mockResolvedValue({
234
+ ipBlockingEnabled: true,
235
+ rateLimitsEnabled: false,
236
+ updatedAt: null,
237
+ updatedBy: null,
238
+ });
239
+ mocks.limit.mockResolvedValue({
240
+ success: false,
241
+ limit: 1,
242
+ remaining: 0,
243
+ reset: Date.now() + 60_000,
244
+ });
245
+ mocks.validateEmoji.mockResolvedValue(null);
246
+
247
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
248
+ await import('../api-proxy-guard.js');
249
+ clearApiProxyGuardLimiterCache();
250
+
251
+ const response = await guardApiProxyRequest(
252
+ makeRequest('/api/test', 'POST'),
253
+ {
254
+ additionalRoutePolicies: [
255
+ {
256
+ key: 'rate-limit-toggle-test',
257
+ matches: () => true,
258
+ rateLimits: {
259
+ get: [],
260
+ mutate: [
261
+ {
262
+ duration: '1 m',
263
+ limit: 1,
264
+ window: 'minute',
265
+ },
266
+ ],
267
+ },
268
+ },
269
+ ],
270
+ prefixBase: 'proxy:test:api',
271
+ }
272
+ );
273
+
274
+ expect(response).toBeNull();
275
+ expect(mocks.isBlocked).toHaveBeenCalledWith('1.2.3.4');
276
+ expect(mocks.getTrustEntries).not.toHaveBeenCalled();
277
+ expect(mocks.limit).not.toHaveBeenCalled();
278
+ expect(mocks.validateEmoji).toHaveBeenCalled();
279
+ });
280
+
180
281
  it('allows a blocked IP to submit the rate-limit appeal endpoint only', async () => {
181
282
  vi.stubEnv('NODE_ENV', 'production');
182
283
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
@@ -1729,7 +1830,37 @@ describe('guardApiProxyRequest', () => {
1729
1830
  expect(response?.headers.get('X-RateLimit-Policy')).toBe('password-login');
1730
1831
  });
1731
1832
 
1732
- it('keeps password login, OTP send, and OTP verify on distinct limiter prefixes', async () => {
1833
+ it('uses a separate recovery-code bucket for auth recovery consume', async () => {
1834
+ vi.stubEnv('NODE_ENV', 'production');
1835
+ vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1836
+ vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
1837
+ mocks.redis.mockReturnValue({});
1838
+ mocks.extractIp.mockReturnValue('1.2.3.4');
1839
+ mocks.isBlocked.mockResolvedValue(null);
1840
+ mocks.limit.mockResolvedValueOnce({
1841
+ success: false,
1842
+ limit: 10,
1843
+ remaining: 0,
1844
+ reset: Date.now() + 15_000,
1845
+ });
1846
+
1847
+ const { guardApiProxyRequest, clearApiProxyGuardLimiterCache } =
1848
+ await import('../api-proxy-guard.js');
1849
+ clearApiProxyGuardLimiterCache();
1850
+
1851
+ const response = await guardApiProxyRequest(
1852
+ makeRequest('/api/v1/auth/recovery/consume', 'POST'),
1853
+ { prefixBase: 'proxy:test:api' }
1854
+ );
1855
+
1856
+ expect(response?.status).toBe(429);
1857
+ expect(response?.headers.get('X-RateLimit-Limit')).toBe('10');
1858
+ expect(response?.headers.get('X-RateLimit-Policy')).toBe(
1859
+ 'auth-recovery-code'
1860
+ );
1861
+ });
1862
+
1863
+ it('keeps password login, OTP send, OTP verify, and recovery code on distinct limiter prefixes', async () => {
1733
1864
  vi.stubEnv('NODE_ENV', 'production');
1734
1865
  vi.stubEnv('UPSTASH_REDIS_REST_URL', 'https://redis.test');
1735
1866
  vi.stubEnv('UPSTASH_REDIS_REST_TOKEN', 'token');
@@ -1756,6 +1887,9 @@ describe('guardApiProxyRequest', () => {
1756
1887
  await guardApiProxyRequest(makeRequest('/api/v1/auth/otp/verify'), {
1757
1888
  prefixBase: 'proxy:test:api',
1758
1889
  });
1890
+ await guardApiProxyRequest(makeRequest('/api/v1/auth/recovery/consume'), {
1891
+ prefixBase: 'proxy:test:api',
1892
+ });
1759
1893
 
1760
1894
  expect(mocks.ratelimitPrefixes).toContain(
1761
1895
  'proxy:test:api:password-login:anonymous:mutate:minute'
@@ -1766,6 +1900,9 @@ describe('guardApiProxyRequest', () => {
1766
1900
  expect(mocks.ratelimitPrefixes).toContain(
1767
1901
  'proxy:test:api:otp-verify:anonymous:mutate:minute'
1768
1902
  );
1903
+ expect(mocks.ratelimitPrefixes).toContain(
1904
+ 'proxy:test:api:auth-recovery-code:anonymous:mutate:minute'
1905
+ );
1769
1906
  expect(mocks.ratelimitPrefixes).not.toContain(
1770
1907
  'proxy:test:api:auth:anonymous:mutate:minute'
1771
1908
  );
@@ -1,3 +1,4 @@
1
+ import type { JSONContent } from '@tiptap/core';
1
2
  import { describe, expect, it } from 'vitest';
2
3
  import {
3
4
  createTaskDescriptionPayload,
@@ -13,6 +14,28 @@ import {
13
14
  taskDescriptionYjsStateToBase64,
14
15
  } from '../task-description-codec';
15
16
 
17
+ function findNode(
18
+ content: JSONContent | undefined,
19
+ type: string
20
+ ): JSONContent | undefined {
21
+ if (!content) return undefined;
22
+ if (content.type === type) return content;
23
+
24
+ for (const child of content.content ?? []) {
25
+ const match = findNode(child, type);
26
+ if (match) return match;
27
+ }
28
+ }
29
+
30
+ function cellTexts(row: JSONContent | undefined) {
31
+ return (row?.content ?? []).map((cell) =>
32
+ taskDescriptionPlainText({
33
+ content: cell.content ?? [],
34
+ type: 'doc',
35
+ })
36
+ );
37
+ }
38
+
16
39
  describe('task description codec', () => {
17
40
  it('creates storage payloads from plain text', () => {
18
41
  const payload = createTaskDescriptionPayload(
@@ -52,6 +75,90 @@ describe('task description codec', () => {
52
75
  expect(plainText).toContain('const shipped = true;');
53
76
  });
54
77
 
78
+ it('parses GFM pipe tables into TipTap table nodes', () => {
79
+ const content = parseTaskDescriptionInput(
80
+ [
81
+ '| **Item** | Owner |',
82
+ '| :--- | ---: |',
83
+ '| API | [Sam](https://example.com) |',
84
+ '| CLI | `ttr` |',
85
+ ].join('\n'),
86
+ 'markdown'
87
+ );
88
+ const table = findNode(content, 'table');
89
+ const rows = table?.content ?? [];
90
+
91
+ expect(table).toBeDefined();
92
+ expect(rows).toHaveLength(3);
93
+ expect(rows[0]?.content?.map((cell) => cell.type)).toEqual([
94
+ 'tableHeader',
95
+ 'tableHeader',
96
+ ]);
97
+ expect(cellTexts(rows[0])).toEqual(['Item', 'Owner']);
98
+ expect(cellTexts(rows[1])).toEqual(['API', 'Sam']);
99
+
100
+ const firstHeaderText = rows[0]?.content?.[0]?.content?.[0]?.content?.[0];
101
+ expect(firstHeaderText).toMatchObject({
102
+ marks: [expect.objectContaining({ type: 'bold' })],
103
+ text: 'Item',
104
+ type: 'text',
105
+ });
106
+ });
107
+
108
+ it('keeps escaped pipes inside markdown table cells', () => {
109
+ const content = parseTaskDescriptionInput(
110
+ ['Name | Notes', '--- | ---', 'API | Keep A\\|B together'].join('\n'),
111
+ 'markdown'
112
+ );
113
+ const table = findNode(content, 'table');
114
+ const rows = table?.content ?? [];
115
+
116
+ expect(cellTexts(rows[1])).toEqual(['API', 'Keep A|B together']);
117
+ });
118
+
119
+ it('pads ragged markdown table rows into rectangular table rows', () => {
120
+ const content = parseTaskDescriptionInput(
121
+ [
122
+ '| A | B | C |',
123
+ '| --- | --- | --- |',
124
+ '| 1 | 2 |',
125
+ '| 3 | 4 | 5 | 6 |',
126
+ ].join('\n'),
127
+ 'markdown'
128
+ );
129
+ const table = findNode(content, 'table');
130
+ const rows = table?.content ?? [];
131
+
132
+ expect(rows.map((row) => row.content?.length)).toEqual([4, 4, 4]);
133
+ expect(cellTexts(rows[1])).toEqual(['1', '2', '', '']);
134
+ expect(cellTexts(rows[2])).toEqual(['3', '4', '5', '6']);
135
+ });
136
+
137
+ it('keeps non-table pipe text as paragraph text', () => {
138
+ const content = parseTaskDescriptionInput(
139
+ ['This mentions A | B as text.', 'There is no delimiter row.'].join('\n'),
140
+ 'markdown'
141
+ );
142
+
143
+ expect(findNode(content, 'table')).toBeUndefined();
144
+ expect(taskDescriptionPlainText(content)).toContain('A | B as text');
145
+ });
146
+
147
+ it('round-trips markdown tables through Yjs state', () => {
148
+ const payload = createTaskDescriptionPayload(
149
+ ['| Field | Value |', '| --- | --- |', '| Priority | High |'].join('\n'),
150
+ 'markdown'
151
+ );
152
+ const decoded = decodeTaskDescriptionYjsState(
153
+ payload.description_yjs_state ?? []
154
+ );
155
+ const table = findNode(decoded, 'table');
156
+ const rows = table?.content ?? [];
157
+
158
+ expect(table).toBeDefined();
159
+ expect(cellTexts(rows[1])).toEqual(['Priority', 'High']);
160
+ });
161
+
55
162
  it('round-trips TipTap content through Yjs base64', () => {
56
163
  const content = parseTaskDescriptionInput(
57
164
  JSON.stringify({
@@ -15,7 +15,11 @@ vi.mock('../../upstash-rest', () => ({
15
15
  }));
16
16
 
17
17
  import {
18
+ blockIPEdge,
19
+ clearEdgeAbuseProtectionControlsCache,
20
+ EDGE_ABUSE_PROTECTION_CONTROLS_REDIS_KEY,
18
21
  extractIPFromRequest,
22
+ isIPBlockedEdge,
19
23
  recordMalformedAuthCookieEdge,
20
24
  recordSuspiciousApiRequestEdge,
21
25
  } from '../edge';
@@ -25,6 +29,7 @@ describe('abuse-protection edge', () => {
25
29
  vi.useFakeTimers();
26
30
  vi.setSystemTime(new Date());
27
31
  vi.clearAllMocks();
32
+ clearEdgeAbuseProtectionControlsCache();
28
33
  mocks.getRedis.mockResolvedValue(mocks.redis);
29
34
  mocks.redis.expire.mockResolvedValue(1);
30
35
  mocks.redis.get.mockResolvedValue(0);
@@ -61,6 +66,61 @@ describe('abuse-protection edge', () => {
61
66
  });
62
67
  });
63
68
 
69
+ describe('edge abuse protection controls', () => {
70
+ it('does not enforce cached IP blocks when IP blocking is disabled', async () => {
71
+ mocks.redis.get.mockImplementation((key: string) => {
72
+ if (key === EDGE_ABUSE_PROTECTION_CONTROLS_REDIS_KEY) {
73
+ return Promise.resolve(
74
+ JSON.stringify({
75
+ ipBlockingEnabled: false,
76
+ rateLimitsEnabled: true,
77
+ })
78
+ );
79
+ }
80
+
81
+ return Promise.resolve(
82
+ JSON.stringify({
83
+ id: 'block-1',
84
+ level: 2,
85
+ reason: 'api_abuse',
86
+ blockedAt: new Date().toISOString(),
87
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
88
+ })
89
+ );
90
+ });
91
+
92
+ const blockInfo = await isIPBlockedEdge('203.0.113.40');
93
+
94
+ expect(blockInfo).toBeNull();
95
+ expect(mocks.redis.get).toHaveBeenCalledWith(
96
+ EDGE_ABUSE_PROTECTION_CONTROLS_REDIS_KEY
97
+ );
98
+ expect(mocks.redis.get).not.toHaveBeenCalledWith(
99
+ 'ip:blocked:203.0.113.40'
100
+ );
101
+ });
102
+
103
+ it('does not create edge IP blocks when IP blocking is disabled', async () => {
104
+ mocks.redis.get.mockImplementation((key: string) => {
105
+ if (key === EDGE_ABUSE_PROTECTION_CONTROLS_REDIS_KEY) {
106
+ return Promise.resolve(
107
+ JSON.stringify({
108
+ ipBlockingEnabled: false,
109
+ rateLimitsEnabled: true,
110
+ })
111
+ );
112
+ }
113
+
114
+ return Promise.resolve(0);
115
+ });
116
+
117
+ const blockInfo = await blockIPEdge('203.0.113.50', 'api_abuse');
118
+
119
+ expect(blockInfo).toBeNull();
120
+ expect(mocks.redis.set).not.toHaveBeenCalled();
121
+ });
122
+ });
123
+
64
124
  describe('recordMalformedAuthCookieEdge', () => {
65
125
  it('tracks malformed-cookie hits without blocking on the first offense', async () => {
66
126
  mocks.redis.incr.mockResolvedValueOnce(1);
@@ -21,6 +21,38 @@ import type { BlockInfo } from './types';
21
21
  let edgeRedisClient: UpstashRestRedisClient | null = null;
22
22
  let edgeRedisInitialized = false;
23
23
 
24
+ export const EDGE_ABUSE_PROTECTION_CONTROLS_REDIS_KEY =
25
+ 'infrastructure:edge-abuse-protection-controls';
26
+
27
+ const EDGE_ABUSE_PROTECTION_CONTROLS_CACHE_TTL_MS = 5_000;
28
+
29
+ export interface EdgeAbuseProtectionControls {
30
+ ipBlockingEnabled: boolean;
31
+ rateLimitsEnabled: boolean;
32
+ updatedAt: string | null;
33
+ updatedBy: string | null;
34
+ }
35
+
36
+ export interface EdgeAbuseProtectionControlsPatch {
37
+ ipBlockingEnabled?: boolean;
38
+ rateLimitsEnabled?: boolean;
39
+ updatedAt?: string;
40
+ updatedBy?: string | null;
41
+ }
42
+
43
+ export const DEFAULT_EDGE_ABUSE_PROTECTION_CONTROLS: EdgeAbuseProtectionControls =
44
+ {
45
+ ipBlockingEnabled: true,
46
+ rateLimitsEnabled: true,
47
+ updatedAt: null,
48
+ updatedBy: null,
49
+ };
50
+
51
+ let edgeAbuseProtectionControlsCache: {
52
+ expiresAt: number;
53
+ value: EdgeAbuseProtectionControls;
54
+ } | null = null;
55
+
24
56
  function parsePositiveIntEnv(name: string, fallback: number): number {
25
57
  const rawValue = process.env[name];
26
58
  if (!rawValue) {
@@ -44,6 +76,111 @@ async function getEdgeRedisClient() {
44
76
  }
45
77
  }
46
78
 
79
+ function normalizeEdgeAbuseProtectionControls(
80
+ value: unknown
81
+ ): EdgeAbuseProtectionControls {
82
+ let parsed = value;
83
+ if (typeof value === 'string') {
84
+ try {
85
+ parsed = JSON.parse(value);
86
+ } catch {
87
+ parsed = null;
88
+ }
89
+ }
90
+
91
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
92
+ return DEFAULT_EDGE_ABUSE_PROTECTION_CONTROLS;
93
+ }
94
+
95
+ const record = parsed as Record<string, unknown>;
96
+ return {
97
+ ipBlockingEnabled:
98
+ typeof record.ipBlockingEnabled === 'boolean'
99
+ ? record.ipBlockingEnabled
100
+ : DEFAULT_EDGE_ABUSE_PROTECTION_CONTROLS.ipBlockingEnabled,
101
+ rateLimitsEnabled:
102
+ typeof record.rateLimitsEnabled === 'boolean'
103
+ ? record.rateLimitsEnabled
104
+ : DEFAULT_EDGE_ABUSE_PROTECTION_CONTROLS.rateLimitsEnabled,
105
+ updatedAt:
106
+ typeof record.updatedAt === 'string' && record.updatedAt.trim()
107
+ ? record.updatedAt
108
+ : null,
109
+ updatedBy:
110
+ typeof record.updatedBy === 'string' && record.updatedBy.trim()
111
+ ? record.updatedBy
112
+ : null,
113
+ };
114
+ }
115
+
116
+ export function clearEdgeAbuseProtectionControlsCache() {
117
+ edgeAbuseProtectionControlsCache = null;
118
+ }
119
+
120
+ export async function readEdgeAbuseProtectionControls(options?: {
121
+ allowCache?: boolean;
122
+ }): Promise<EdgeAbuseProtectionControls> {
123
+ const allowCache = options?.allowCache ?? true;
124
+ const now = Date.now();
125
+
126
+ if (
127
+ allowCache &&
128
+ edgeAbuseProtectionControlsCache &&
129
+ edgeAbuseProtectionControlsCache.expiresAt > now
130
+ ) {
131
+ return edgeAbuseProtectionControlsCache.value;
132
+ }
133
+
134
+ try {
135
+ const redis = await getEdgeRedisClient();
136
+ if (!redis) {
137
+ return DEFAULT_EDGE_ABUSE_PROTECTION_CONTROLS;
138
+ }
139
+
140
+ const stored = await redis.get<unknown>(
141
+ EDGE_ABUSE_PROTECTION_CONTROLS_REDIS_KEY
142
+ );
143
+ const controls = normalizeEdgeAbuseProtectionControls(stored);
144
+ edgeAbuseProtectionControlsCache = {
145
+ expiresAt: now + EDGE_ABUSE_PROTECTION_CONTROLS_CACHE_TTL_MS,
146
+ value: controls,
147
+ };
148
+
149
+ return controls;
150
+ } catch {
151
+ // Fail-open: keep the platform reachable when the controls store is down.
152
+ return DEFAULT_EDGE_ABUSE_PROTECTION_CONTROLS;
153
+ }
154
+ }
155
+
156
+ export async function writeEdgeAbuseProtectionControls(
157
+ patch: EdgeAbuseProtectionControlsPatch
158
+ ): Promise<EdgeAbuseProtectionControls> {
159
+ const redis = await getEdgeRedisClient();
160
+ if (!redis) {
161
+ throw new Error('Edge abuse protection controls store is unavailable');
162
+ }
163
+
164
+ const current = await readEdgeAbuseProtectionControls({ allowCache: false });
165
+ const next: EdgeAbuseProtectionControls = {
166
+ ipBlockingEnabled: patch.ipBlockingEnabled ?? current.ipBlockingEnabled,
167
+ rateLimitsEnabled: patch.rateLimitsEnabled ?? current.rateLimitsEnabled,
168
+ updatedAt: patch.updatedAt ?? new Date().toISOString(),
169
+ updatedBy: patch.updatedBy ?? null,
170
+ };
171
+
172
+ await redis.set(
173
+ EDGE_ABUSE_PROTECTION_CONTROLS_REDIS_KEY,
174
+ JSON.stringify(next)
175
+ );
176
+ edgeAbuseProtectionControlsCache = {
177
+ expiresAt: Date.now() + EDGE_ABUSE_PROTECTION_CONTROLS_CACHE_TTL_MS,
178
+ value: next,
179
+ };
180
+
181
+ return next;
182
+ }
183
+
47
184
  /**
48
185
  * Check if an IP is blocked using Redis cache only (no DB fallback).
49
186
  * Designed for Edge Runtime where speed > completeness.
@@ -53,6 +190,9 @@ export async function isIPBlockedEdge(
53
190
  ipAddress: string
54
191
  ): Promise<BlockInfo | null> {
55
192
  try {
193
+ const controls = await readEdgeAbuseProtectionControls();
194
+ if (!controls.ipBlockingEnabled) return null;
195
+
56
196
  const redis = await getEdgeRedisClient();
57
197
  if (!redis) return null;
58
198
 
@@ -80,6 +220,9 @@ export async function blockIPEdge(
80
220
  reason: BlockInfo['reason']
81
221
  ): Promise<BlockInfo | null> {
82
222
  try {
223
+ const controls = await readEdgeAbuseProtectionControls();
224
+ if (!controls.ipBlockingEnabled) return null;
225
+
83
226
  const redis = await getEdgeRedisClient();
84
227
  if (!redis) return null;
85
228
 
@@ -1,7 +1,11 @@
1
1
  import { Ratelimit } from '@upstash/ratelimit';
2
2
  import type { NextRequest } from 'next/server';
3
3
  import { NextResponse } from 'next/server';
4
- import { extractIPFromRequest, isIPBlockedEdge } from './abuse-protection/edge';
4
+ import {
5
+ extractIPFromRequest,
6
+ isIPBlockedEdge,
7
+ readEdgeAbuseProtectionControls,
8
+ } from './abuse-protection/edge';
5
9
  import {
6
10
  type CachedTrustEntry,
7
11
  getCachedTrustEntries,
@@ -257,6 +261,20 @@ const OTP_VERIFY_RATE_LIMITS: RateLimitProfile = {
257
261
  ],
258
262
  };
259
263
 
264
+ const AUTH_RECOVERY_CODE_RATE_LIMITS: RateLimitProfile = {
265
+ get: NO_READ_RATE_LIMITS,
266
+ mutate: [
267
+ createConfig(
268
+ 'minute',
269
+ '1 m',
270
+ 10,
271
+ 'API_PROXY_AUTH_RECOVERY_CODE_LIMIT_MINUTE'
272
+ ),
273
+ createConfig('hour', '1 h', 50, 'API_PROXY_AUTH_RECOVERY_CODE_LIMIT_HOUR'),
274
+ createConfig('day', '1 d', 120, 'API_PROXY_AUTH_RECOVERY_CODE_LIMIT_DAY'),
275
+ ],
276
+ };
277
+
260
278
  const RATE_LIMIT_APPEAL_RATE_LIMITS: RateLimitProfile = {
261
279
  get: NO_READ_RATE_LIMITS,
262
280
  mutate: [
@@ -494,6 +512,12 @@ const DEFAULT_ROUTE_POLICIES: ProxyRoutePolicy[] = [
494
512
  /^\/api\/v1\/auth\/mobile\/verify-otp(?:\/|$)/.test(req.nextUrl.pathname),
495
513
  rateLimits: OTP_VERIFY_RATE_LIMITS,
496
514
  },
515
+ {
516
+ key: 'auth-recovery-code',
517
+ matches: (req) =>
518
+ /^\/api\/v1\/auth\/recovery\/consume(?:\/|$)/.test(req.nextUrl.pathname),
519
+ rateLimits: AUTH_RECOVERY_CODE_RATE_LIMITS,
520
+ },
497
521
  {
498
522
  key: 'cross-app-return',
499
523
  matches: (req) =>
@@ -1294,8 +1318,9 @@ export async function guardApiProxyRequest(
1294
1318
  const ip = extractIPFromRequest(req.headers);
1295
1319
  const identity = await resolveProxyIdentity(req, ip);
1296
1320
  const routePolicy = getRoutePolicy(req, routePolicies);
1321
+ const abuseProtectionControls = await readEdgeAbuseProtectionControls();
1297
1322
 
1298
- if (ip !== 'unknown') {
1323
+ if (abuseProtectionControls.ipBlockingEnabled && ip !== 'unknown') {
1299
1324
  const blockInfo = await isIPBlockedEdge(ip);
1300
1325
  if (blockInfo) {
1301
1326
  const hasTemporaryRelief =
@@ -1338,7 +1363,7 @@ export async function guardApiProxyRequest(
1338
1363
  // escape the shared IP bucket.
1339
1364
  let trustEntry: CachedTrustEntry | null = null;
1340
1365
  let sessionScoped = false;
1341
- if (edgeTrustEnabled()) {
1366
+ if (abuseProtectionControls.rateLimitsEnabled && edgeTrustEnabled()) {
1342
1367
  const lookupKeys = [
1343
1368
  identity.sessionKey,
1344
1369
  ...(isRead
@@ -1373,7 +1398,7 @@ export async function guardApiProxyRequest(
1373
1398
  ? identity.sessionKey
1374
1399
  : identity.ipKey;
1375
1400
 
1376
- if (limiterId) {
1401
+ if (abuseProtectionControls.rateLimitsEnabled && limiterId) {
1377
1402
  const effectiveCallerClass: CallerClass = sessionScoped
1378
1403
  ? 'authenticated'
1379
1404
  : callerClass;
@@ -15,10 +15,14 @@ describe('createTuturuuuNextConfig', () => {
15
15
 
16
16
  expect(config.allowedDevOrigins).toContain('tuturuuu.localhost');
17
17
  expect(config.cacheComponents).toBe(true);
18
+ expect(config.partialPrefetching).toBe(true);
19
+ expect(config.experimental?.turbopackFileSystemCacheForBuild).toBe(true);
20
+ expect(config.experimental?.turbopackRustReactCompiler).toBe(true);
18
21
  expect(config.images?.remotePatterns).toEqual(
19
22
  TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS
20
23
  );
21
24
  expect(config.poweredByHeader).toBe(false);
25
+ expect(config.reactCompiler).toBe(true);
22
26
  expect(config.reactStrictMode).toBe(true);
23
27
  expect(config.typescript?.ignoreBuildErrors).toBe(true);
24
28
  });
@@ -95,10 +99,13 @@ describe('createTuturuuuNextConfig', () => {
95
99
  it('preserves app-specific config overrides', () => {
96
100
  const config = createTuturuuuNextConfig({
97
101
  cacheComponents: false,
98
- reactCompiler: true,
102
+ partialPrefetching: false,
103
+ reactCompiler: false,
99
104
  transpilePackages: ['@tuturuuu/ui'],
100
105
  experimental: {
101
106
  cpus: 2,
107
+ turbopackFileSystemCacheForBuild: false,
108
+ turbopackRustReactCompiler: false,
102
109
  },
103
110
  typescript: {
104
111
  ignoreBuildErrors: false,
@@ -106,11 +113,43 @@ describe('createTuturuuuNextConfig', () => {
106
113
  });
107
114
 
108
115
  expect(config.cacheComponents).toBe(false);
116
+ expect(config.partialPrefetching).toBe(false);
109
117
  expect(config.reactCompiler).toBe(true);
110
118
  expect(config.transpilePackages).toEqual(['@tuturuuu/ui']);
111
119
  expect(config.experimental?.cpus).toBe(2);
120
+ expect(config.experimental?.turbopackFileSystemCacheForBuild).toBe(false);
121
+ expect(config.experimental?.turbopackRustReactCompiler).toBe(false);
112
122
  expect(config.typescript?.ignoreBuildErrors).toBe(false);
113
123
  });
124
+
125
+ it('keeps cache components enabled for all apps by default', () => {
126
+ const originalCacheComponents = process.env.TUTURUUU_NEXT_CACHE_COMPONENTS;
127
+
128
+ try {
129
+ process.env.TUTURUUU_NEXT_CACHE_COMPONENTS = '0';
130
+
131
+ const config = createTuturuuuNextConfig();
132
+
133
+ expect(config.cacheComponents).toBe(true);
134
+ expect(config.partialPrefetching).toBe(true);
135
+ } finally {
136
+ if (originalCacheComponents === undefined) {
137
+ delete process.env.TUTURUUU_NEXT_CACHE_COMPONENTS;
138
+ } else {
139
+ process.env.TUTURUUU_NEXT_CACHE_COMPONENTS = originalCacheComponents;
140
+ }
141
+ }
142
+ });
143
+
144
+ it('allows apps to explicitly override partial prefetching', () => {
145
+ const config = createTuturuuuNextConfig({
146
+ cacheComponents: true,
147
+ partialPrefetching: false,
148
+ });
149
+
150
+ expect(config.cacheComponents).toBe(true);
151
+ expect(config.partialPrefetching).toBe(false);
152
+ });
114
153
  });
115
154
 
116
155
  describe('isTuturuuuNextCacheComponentsEnabled', () => {
@@ -118,12 +157,12 @@ describe('isTuturuuuNextCacheComponentsEnabled', () => {
118
157
  expect(isTuturuuuNextCacheComponentsEnabled({})).toBe(true);
119
158
  });
120
159
 
121
- it('honors explicit environment overrides', () => {
160
+ it('ignores deprecated environment opt-outs', () => {
122
161
  expect(
123
162
  isTuturuuuNextCacheComponentsEnabled({
124
163
  TUTURUUU_NEXT_CACHE_COMPONENTS: '0',
125
164
  })
126
- ).toBe(false);
165
+ ).toBe(true);
127
166
  expect(
128
167
  isTuturuuuNextCacheComponentsEnabled({
129
168
  TUTURUUU_NEXT_CACHE_COMPONENTS: '1',
@@ -133,10 +172,10 @@ describe('isTuturuuuNextCacheComponentsEnabled', () => {
133
172
  });
134
173
 
135
174
  describe('isTuturuuuNextReactCompilerEnabled', () => {
136
- it('disables React Compiler by default for next dev', () => {
175
+ it('keeps React Compiler enabled for next dev', () => {
137
176
  expect(
138
177
  isTuturuuuNextReactCompilerEnabled({ NODE_ENV: 'development' })
139
- ).toBe(false);
178
+ ).toBe(true);
140
179
  });
141
180
 
142
181
  it('keeps React Compiler enabled by default outside next dev', () => {
@@ -145,13 +184,13 @@ describe('isTuturuuuNextReactCompilerEnabled', () => {
145
184
  );
146
185
  });
147
186
 
148
- it('honors explicit environment overrides', () => {
187
+ it('ignores explicit environment opt-outs', () => {
149
188
  expect(
150
189
  isTuturuuuNextReactCompilerEnabled({
151
190
  NODE_ENV: 'production',
152
191
  TUTURUUU_NEXT_REACT_COMPILER: '0',
153
192
  })
154
- ).toBe(false);
193
+ ).toBe(true);
155
194
  expect(
156
195
  isTuturuuuNextReactCompilerEnabled({
157
196
  NODE_ENV: 'development',
@@ -33,9 +33,6 @@ export const TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS = [
33
33
  },
34
34
  ] satisfies NextImageRemotePattern[];
35
35
 
36
- const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
37
- const FALSY_ENV_VALUES = new Set(['0', 'false', 'no', 'off']);
38
-
39
36
  function mergeStringArrays(
40
37
  first: readonly string[] | undefined,
41
38
  second: readonly string[] | undefined
@@ -60,42 +57,16 @@ function mergeRemotePatterns(
60
57
  return Array.from(merged.values());
61
58
  }
62
59
 
63
- function readBooleanEnvOverride(value: string | undefined) {
64
- const normalizedValue = value?.trim().toLowerCase();
65
-
66
- if (!normalizedValue) {
67
- return undefined;
68
- }
69
-
70
- if (TRUTHY_ENV_VALUES.has(normalizedValue)) {
71
- return true;
72
- }
73
-
74
- if (FALSY_ENV_VALUES.has(normalizedValue)) {
75
- return false;
76
- }
77
-
78
- return undefined;
79
- }
80
-
81
60
  export function isTuturuuuNextReactCompilerEnabled(
82
- env: Environment = process.env
61
+ _env: Environment = process.env
83
62
  ) {
84
- const override = readBooleanEnvOverride(env.TUTURUUU_NEXT_REACT_COMPILER);
85
-
86
- if (override !== undefined) {
87
- return override;
88
- }
89
-
90
- return env.NODE_ENV !== 'development';
63
+ return true;
91
64
  }
92
65
 
93
66
  export function isTuturuuuNextCacheComponentsEnabled(
94
- env: Environment = process.env
67
+ _env: Environment = process.env
95
68
  ) {
96
- const override = readBooleanEnvOverride(env.TUTURUUU_NEXT_CACHE_COMPONENTS);
97
-
98
- return override ?? true;
69
+ return true;
99
70
  }
100
71
 
101
72
  export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
@@ -103,12 +74,13 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
103
74
  const imageConfig = config.images ?? {};
104
75
 
105
76
  return {
106
- reactCompiler: isTuturuuuNextReactCompilerEnabled(),
107
77
  reactStrictMode: true,
108
78
  poweredByHeader: false,
109
79
  ...config,
80
+ reactCompiler: isTuturuuuNextReactCompilerEnabled(),
110
81
  cacheComponents:
111
82
  config.cacheComponents ?? isTuturuuuNextCacheComponentsEnabled(),
83
+ partialPrefetching: config.partialPrefetching ?? true,
112
84
  allowedDevOrigins: mergeStringArrays(
113
85
  getTuturuuuPortlessAllowedDevOrigins(),
114
86
  config.allowedDevOrigins
@@ -126,6 +98,10 @@ export function createTuturuuuNextConfig(config: NextConfig = {}): NextConfig {
126
98
  },
127
99
  experimental: {
128
100
  ...experimentalConfig,
101
+ turbopackFileSystemCacheForBuild:
102
+ experimentalConfig.turbopackFileSystemCacheForBuild ?? true,
103
+ turbopackRustReactCompiler:
104
+ experimentalConfig.turbopackRustReactCompiler ?? true,
129
105
  optimizePackageImports: mergeStringArrays(
130
106
  TUTURUUU_NEXT_OPTIMIZE_PACKAGE_IMPORTS,
131
107
  experimentalConfig.optimizePackageImports
@@ -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.14.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.16.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.14.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.16.0'; // x-release-please-version
4
4
 
5
5
  export type PlatformBuildMetadataInput = {
6
6
  builtAt?: string | null;
@@ -146,6 +146,134 @@ function isHorizontalRule(line: string) {
146
146
  return /^\s{0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/u.test(line);
147
147
  }
148
148
 
149
+ function splitMarkdownTableRow(line: string): string[] | null {
150
+ let trimmed = line.trim();
151
+ if (!trimmed) return null;
152
+
153
+ const hasOuterPipe = trimmed.startsWith('|') || trimmed.endsWith('|');
154
+ if (trimmed.startsWith('|')) trimmed = trimmed.slice(1);
155
+ if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1);
156
+
157
+ const cells: string[] = [];
158
+ let cell = '';
159
+ let foundSeparator = false;
160
+
161
+ for (let index = 0; index < trimmed.length; index += 1) {
162
+ const character = trimmed[index];
163
+ const nextCharacter = trimmed[index + 1];
164
+
165
+ if (character === '\\' && nextCharacter === '|') {
166
+ cell += '|';
167
+ index += 1;
168
+ continue;
169
+ }
170
+
171
+ if (character === '|') {
172
+ cells.push(cell.trim());
173
+ cell = '';
174
+ foundSeparator = true;
175
+ continue;
176
+ }
177
+
178
+ cell += character;
179
+ }
180
+
181
+ cells.push(cell.trim());
182
+
183
+ if (!(hasOuterPipe || foundSeparator)) return null;
184
+ return cells;
185
+ }
186
+
187
+ function isMarkdownTableDelimiterCell(cell: string) {
188
+ return /^:?-{3,}:?$/u.test(cell.trim());
189
+ }
190
+
191
+ function getMarkdownTableDelimiterCells(line: string) {
192
+ const cells = splitMarkdownTableRow(line);
193
+ if (!cells || cells.length === 0) return null;
194
+ return cells.every(isMarkdownTableDelimiterCell) ? cells : null;
195
+ }
196
+
197
+ function isMarkdownTableStart(lines: string[], index: number) {
198
+ const headerCells = splitMarkdownTableRow(lines[index] ?? '');
199
+ const delimiterCells = getMarkdownTableDelimiterCells(lines[index + 1] ?? '');
200
+
201
+ return Boolean(
202
+ headerCells &&
203
+ headerCells.length > 0 &&
204
+ delimiterCells &&
205
+ delimiterCells.length === headerCells.length
206
+ );
207
+ }
208
+
209
+ function tableCellFromText(
210
+ text: string,
211
+ type: 'tableCell' | 'tableHeader'
212
+ ): JSONContent {
213
+ return {
214
+ attrs: { colspan: 1, rowspan: 1, colwidth: null },
215
+ content: [paragraphFromText(text, true)],
216
+ type,
217
+ };
218
+ }
219
+
220
+ function padTableRow(cells: string[], columnCount: number) {
221
+ return Array.from({ length: columnCount }, (_, index) => cells[index] ?? '');
222
+ }
223
+
224
+ function parseMarkdownTable(
225
+ lines: string[],
226
+ startIndex: number
227
+ ): { block: JSONContent; endIndex: number } | null {
228
+ const headerCells = splitMarkdownTableRow(lines[startIndex] ?? '');
229
+ const delimiterCells = getMarkdownTableDelimiterCells(
230
+ lines[startIndex + 1] ?? ''
231
+ );
232
+
233
+ if (!headerCells || !delimiterCells) return null;
234
+ if (delimiterCells.length !== headerCells.length) return null;
235
+
236
+ const bodyRows: string[][] = [];
237
+ let index = startIndex + 2;
238
+
239
+ while (index < lines.length) {
240
+ const line = lines[index] ?? '';
241
+ if (!line.trim() || isBlockStart(line)) break;
242
+
243
+ const rowCells = splitMarkdownTableRow(line);
244
+ if (!rowCells) break;
245
+ bodyRows.push(rowCells);
246
+ index += 1;
247
+ }
248
+
249
+ const columnCount = Math.max(
250
+ headerCells.length,
251
+ delimiterCells.length,
252
+ ...bodyRows.map((row) => row.length)
253
+ );
254
+
255
+ return {
256
+ block: {
257
+ content: [
258
+ {
259
+ content: padTableRow(headerCells, columnCount).map((cell) =>
260
+ tableCellFromText(cell, 'tableHeader')
261
+ ),
262
+ type: 'tableRow',
263
+ },
264
+ ...bodyRows.map((row) => ({
265
+ content: padTableRow(row, columnCount).map((cell) =>
266
+ tableCellFromText(cell, 'tableCell')
267
+ ),
268
+ type: 'tableRow',
269
+ })),
270
+ ],
271
+ type: 'table',
272
+ },
273
+ endIndex: index,
274
+ };
275
+ }
276
+
149
277
  function getListLine(line: string):
150
278
  | {
151
279
  checked?: boolean;
@@ -288,12 +416,22 @@ function parseMarkdownBlocks(markdown: string): JSONContent[] {
288
416
  continue;
289
417
  }
290
418
 
419
+ if (isMarkdownTableStart(lines, index)) {
420
+ const table = parseMarkdownTable(lines, index);
421
+ if (table) {
422
+ blocks.push(table.block);
423
+ index = table.endIndex;
424
+ continue;
425
+ }
426
+ }
427
+
291
428
  const paragraphLines = [line];
292
429
  index += 1;
293
430
  while (
294
431
  index < lines.length &&
295
432
  (lines[index] ?? '').trim() &&
296
- !isBlockStart(lines[index] ?? '')
433
+ !isBlockStart(lines[index] ?? '') &&
434
+ !isMarkdownTableStart(lines, index)
297
435
  ) {
298
436
  paragraphLines.push(lines[index] ?? '');
299
437
  index += 1;