@tuturuuu/utils 0.11.0 → 0.12.1
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 +22 -0
- package/package.json +8 -8
- package/src/__tests__/api-proxy-guard.test.ts +138 -1
- package/src/__tests__/app-url.test.ts +30 -0
- package/src/__tests__/task-description-codec.test.ts +107 -0
- package/src/abuse-protection/__tests__/edge.test.ts +60 -0
- package/src/abuse-protection/edge.ts +143 -0
- package/src/api-proxy-guard.ts +29 -4
- package/src/internal-domains.ts +4 -0
- package/src/next-config.test.ts +46 -7
- package/src/next-config.ts +10 -34
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/task-description-codec.ts +139 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.1](https://github.com/tutur3u/platform/compare/utils-v0.12.0...utils-v0.12.1) (2026-07-03)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **infrastructure:** restore canonical auth host access ([9f6715f](https://github.com/tutur3u/platform/commit/9f6715fb1c5a89598556cba33b16938b76995fc6))
|
|
9
|
+
|
|
10
|
+
## [0.12.0](https://github.com/tutur3u/platform/compare/utils-v0.11.0...utils-v0.12.0) (2026-07-02)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* **infrastructure:** add abuse protection controls ([0e09692](https://github.com/tutur3u/platform/commit/0e09692a553cb10fbbb521e921951c2a6863281d))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* **ci:** keep cache components enabled in production ([75e6e2b](https://github.com/tutur3u/platform/commit/75e6e2bbbf6fa432bc496ee95b75ade5f8847f9f))
|
|
21
|
+
* **cli:** preserve markdown tables in task descriptions ([9faf8ba](https://github.com/tutur3u/platform/commit/9faf8ba44b92565a9dfc2949c496654fc1fea9b3))
|
|
22
|
+
* **infrastructure:** default docker web to native builds ([7d89f00](https://github.com/tutur3u/platform/commit/7d89f004f75ab12eddc3cb92df5a1e6a7255fe0d))
|
|
23
|
+
* **security:** limit auth recovery code attempts ([72d454c](https://github.com/tutur3u/platform/commit/72d454c507b0d4b7e69c7b38ad2099dbb83193b2))
|
|
24
|
+
|
|
3
25
|
## [0.11.0](https://github.com/tutur3u/platform/compare/utils-v0.10.0...utils-v0.11.0) (2026-06-29)
|
|
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.
|
|
4
|
+
"version": "0.12.1",
|
|
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.
|
|
22
|
+
"@tanstack/react-query": "^5.101.2",
|
|
23
23
|
"@tiptap/core": "3.27.1",
|
|
24
24
|
"@tiptap/react": "3.27.1",
|
|
25
|
-
"@tuturuuu/google": "0.0.
|
|
25
|
+
"@tuturuuu/google": "0.0.3",
|
|
26
26
|
"@tuturuuu/icons": "0.0.6",
|
|
27
|
-
"@tuturuuu/internal-api": "0.
|
|
27
|
+
"@tuturuuu/internal-api": "0.14.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.
|
|
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.
|
|
48
|
+
"@tuturuuu/types": "0.14.0",
|
|
49
49
|
"@tuturuuu/typescript-config": "0.1.1",
|
|
50
50
|
"@types/diff": "^8.0.0",
|
|
51
|
-
"@types/node": "^26.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.
|
|
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('
|
|
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
|
);
|
|
@@ -137,6 +137,29 @@ describe('resolveInternalAppUrl', () => {
|
|
|
137
137
|
).toBeNull();
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
it('recognizes canonical and legacy infrastructure production origins', () => {
|
|
141
|
+
expect(
|
|
142
|
+
getAppDomainByUrl(
|
|
143
|
+
'https://infrastructure.tuturuuu.com/verify-token?nextUrl=%2Fpersonal'
|
|
144
|
+
)
|
|
145
|
+
).toMatchObject({
|
|
146
|
+
canonicalUrl:
|
|
147
|
+
'https://infrastructure.tuturuuu.com/verify-token?nextUrl=%2Fpersonal',
|
|
148
|
+
kind: 'internal',
|
|
149
|
+
name: 'infra',
|
|
150
|
+
});
|
|
151
|
+
expect(
|
|
152
|
+
getAppDomainByUrl(
|
|
153
|
+
'https://infra.tuturuuu.com/verify-token?nextUrl=%2Fpersonal'
|
|
154
|
+
)
|
|
155
|
+
).toMatchObject({
|
|
156
|
+
canonicalUrl:
|
|
157
|
+
'https://infra.tuturuuu.com/verify-token?nextUrl=%2Fpersonal',
|
|
158
|
+
kind: 'internal',
|
|
159
|
+
name: 'infra',
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
140
163
|
it('canonicalizes production internal app URLs to their registered HTTPS origins', () => {
|
|
141
164
|
expect(
|
|
142
165
|
resolveInternalAppUrl({
|
|
@@ -152,6 +175,13 @@ describe('resolveInternalAppUrl', () => {
|
|
|
152
175
|
fallback: 'http://localhost:7807',
|
|
153
176
|
})
|
|
154
177
|
).toBe('https://meet.tuturuuu.com');
|
|
178
|
+
expect(
|
|
179
|
+
resolveInternalAppUrl({
|
|
180
|
+
appName: 'infra',
|
|
181
|
+
candidates: ['http://infrastructure.tuturuuu.com/'],
|
|
182
|
+
fallback: 'http://localhost:7823',
|
|
183
|
+
})
|
|
184
|
+
).toBe('https://infrastructure.tuturuuu.com');
|
|
155
185
|
});
|
|
156
186
|
|
|
157
187
|
it('falls back when every configured URL points at another internal app', () => {
|
|
@@ -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
|
|
package/src/api-proxy-guard.ts
CHANGED
|
@@ -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 {
|
|
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;
|
package/src/internal-domains.ts
CHANGED
package/src/next-config.test.ts
CHANGED
|
@@ -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
|
-
|
|
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('
|
|
160
|
+
it('ignores deprecated environment opt-outs', () => {
|
|
122
161
|
expect(
|
|
123
162
|
isTuturuuuNextCacheComponentsEnabled({
|
|
124
163
|
TUTURUUU_NEXT_CACHE_COMPONENTS: '0',
|
|
125
164
|
})
|
|
126
|
-
).toBe(
|
|
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('
|
|
175
|
+
it('keeps React Compiler enabled for next dev', () => {
|
|
137
176
|
expect(
|
|
138
177
|
isTuturuuuNextReactCompilerEnabled({ NODE_ENV: 'development' })
|
|
139
|
-
).toBe(
|
|
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('
|
|
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(
|
|
193
|
+
).toBe(true);
|
|
155
194
|
expect(
|
|
156
195
|
isTuturuuuNextReactCompilerEnabled({
|
|
157
196
|
NODE_ENV: 'development',
|
package/src/next-config.ts
CHANGED
|
@@ -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
|
-
|
|
61
|
+
_env: Environment = process.env
|
|
83
62
|
) {
|
|
84
|
-
|
|
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
|
-
|
|
67
|
+
_env: Environment = process.env
|
|
95
68
|
) {
|
|
96
|
-
|
|
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.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.17.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.17.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;
|