@tuturuuu/utils 0.33.3 → 0.34.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 +34 -0
- package/package.json +4 -3
- package/src/__tests__/api-proxy-bearer-trust.test.ts +3 -0
- package/src/__tests__/api-proxy-guard.test.ts +6 -13
- package/src/__tests__/api-proxy-offline-response.test.ts +48 -0
- package/src/__tests__/api-proxy-test-request.ts +19 -0
- package/src/__tests__/daily-operation-download-isolation.test.ts +137 -0
- package/src/__tests__/offline-download-guard.test.ts +340 -0
- package/src/__tests__/username-policy.test.ts +70 -0
- package/src/account-benefits-server.ts +43 -0
- package/src/account-benefits.ts +45 -0
- package/src/api-proxy-guard.ts +8 -42
- package/src/api-proxy-read-policy.ts +43 -0
- package/src/constants.ts +3 -0
- package/src/google-calendar-import-fence.test.ts +182 -0
- package/src/google-calendar-import-fence.ts +166 -0
- package/src/offline-download-guard.ts +160 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/playground-schema.test.ts +119 -0
- package/src/playground-schema.ts +173 -0
- package/src/playground-templates.ts +65 -0
- package/src/release-note-policy-core.cjs +28 -0
- package/src/release-note-policy-core.d.cts +5 -0
- package/src/release-note-policy.json +9 -0
- package/src/release-note-policy.ts +4 -0
- package/src/username-policy.json +87 -0
- package/src/username-policy.ts +18 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { verifyTurnstileToken } from '@tuturuuu/turnstile/server';
|
|
2
|
+
import { Ratelimit } from '@upstash/ratelimit';
|
|
3
|
+
import type { NextRequest } from 'next/server';
|
|
4
|
+
import { NextResponse } from 'next/server';
|
|
5
|
+
import { extractIPFromRequest } from './abuse-protection/edge';
|
|
6
|
+
import { getCachedTrustEntries } from './abuse-protection/edge-trust';
|
|
7
|
+
import {
|
|
8
|
+
buildProxySessionSubjectKey,
|
|
9
|
+
getProxySessionSubjectKeyFromCookieHeader,
|
|
10
|
+
} from './api-proxy-guard';
|
|
11
|
+
import {
|
|
12
|
+
getUpstashRatelimitRedisClient,
|
|
13
|
+
getUpstashRestRedisClient,
|
|
14
|
+
} from './upstash-rest';
|
|
15
|
+
|
|
16
|
+
export const OFFLINE_DOWNLOAD_HEADER = 'x-tuturuuu-offline-download';
|
|
17
|
+
const CLEARANCE_SECONDS = 15 * 60;
|
|
18
|
+
const READ_PATH =
|
|
19
|
+
/^\/api\/(?:v1\/)?workspaces\/[^/]+\/(?:finance|inventory|transactions|wallets|tags|members|products|product-categories|product-units|product-warehouses|tasks|task-boards|boards|labels|task-projects|task-initiatives|habit-trackers|habits|calendar)(?:[-/]|$)/u;
|
|
20
|
+
const USER_READ_PATH =
|
|
21
|
+
/^\/api\/v1\/(?:users\/me\/tasks|calendar|users\/calendar-settings)(?:\/|$)/u;
|
|
22
|
+
|
|
23
|
+
export function isOfflineCapableRead(
|
|
24
|
+
request: Pick<NextRequest, 'method' | 'url'>
|
|
25
|
+
) {
|
|
26
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return false;
|
|
27
|
+
const pathname = new URL(request.url).pathname.replace(
|
|
28
|
+
/^\/api\/v1\/mobile-calendar(?=\/api\/)/u,
|
|
29
|
+
''
|
|
30
|
+
);
|
|
31
|
+
return (
|
|
32
|
+
READ_PATH.test(pathname) ||
|
|
33
|
+
USER_READ_PATH.test(pathname) ||
|
|
34
|
+
/^\/api\/v1\/exchange-rates\/?$/u.test(pathname)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function unavailable() {
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{
|
|
41
|
+
message:
|
|
42
|
+
'Download protection is temporarily unavailable. Try again later.',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
status: 503,
|
|
46
|
+
headers: { 'Cache-Control': 'no-store', 'Retry-After': '30' },
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function challenge() {
|
|
52
|
+
return NextResponse.json(
|
|
53
|
+
{
|
|
54
|
+
code: 'ABUSE_CHALLENGE_REQUIRED',
|
|
55
|
+
message: 'Additional verification is required before retrying.',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
status: 403,
|
|
59
|
+
headers: {
|
|
60
|
+
'Cache-Control': 'no-store',
|
|
61
|
+
'X-Abuse-Challenge': 'turnstile',
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Additional protection is scoped to explicitly marked offline bulk downloads.
|
|
68
|
+
* Ordinary enterprise reads retain their existing API/auth protections.
|
|
69
|
+
* Clearance never satisfies authentication, authorization, IP blocks, required
|
|
70
|
+
* MFA, or either rate-limit budget. Runtime failures remain fail-closed. */
|
|
71
|
+
export async function guardOfflineDownloadRequest(request: NextRequest) {
|
|
72
|
+
if (!isOfflineCapableRead(request) || process.env.NODE_ENV !== 'production')
|
|
73
|
+
return null;
|
|
74
|
+
const bulk = request.headers.get(OFFLINE_DOWNLOAD_HEADER) === '1';
|
|
75
|
+
if (!bulk) return null;
|
|
76
|
+
if (process.env.OFFLINE_DOWNLOAD_PROTECTION_ENABLED !== 'true') {
|
|
77
|
+
// Keep baseline API/auth protections while this additional protection is
|
|
78
|
+
// unprovisioned. Offline prefetch is unavailable until explicitly enabled.
|
|
79
|
+
return unavailable();
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const redis = await getUpstashRestRedisClient();
|
|
83
|
+
const limiterRedis = await getUpstashRatelimitRedisClient();
|
|
84
|
+
if (!redis || !limiterRedis) return unavailable();
|
|
85
|
+
const bearer = request.headers
|
|
86
|
+
.get('authorization')
|
|
87
|
+
?.match(/^Bearer\s+([^\s]+)$/i)?.[1];
|
|
88
|
+
const sessionKey = bearer
|
|
89
|
+
? await buildProxySessionSubjectKey('bearer', bearer)
|
|
90
|
+
: await getProxySessionSubjectKeyFromCookieHeader(
|
|
91
|
+
request.headers.get('cookie')
|
|
92
|
+
);
|
|
93
|
+
const ip = extractIPFromRequest(request.headers);
|
|
94
|
+
// IP remains bounded even when a caller rotates unverified bearer values.
|
|
95
|
+
// Internal authenticated gateways may lack a client IP. Never combine
|
|
96
|
+
// their unrelated sessions into a shared unknown-IP bucket. Public ingress
|
|
97
|
+
// still supplies a real IP, retaining the rotating-token budget.
|
|
98
|
+
if (ip === 'unknown' && !sessionKey) return unavailable();
|
|
99
|
+
const subjects = [
|
|
100
|
+
...(ip !== 'unknown'
|
|
101
|
+
? [{ key: `ip:${ip}`, minute: 1200, hour: 30000 }]
|
|
102
|
+
: []),
|
|
103
|
+
...(sessionKey ? [{ key: sessionKey, minute: 600, hour: 12000 }] : []),
|
|
104
|
+
];
|
|
105
|
+
for (const subject of subjects) {
|
|
106
|
+
for (const [window, limit] of [
|
|
107
|
+
['1 m', subject.minute],
|
|
108
|
+
['1 h', subject.hour],
|
|
109
|
+
] as const) {
|
|
110
|
+
const limiter = new Ratelimit({
|
|
111
|
+
redis: limiterRedis,
|
|
112
|
+
limiter: Ratelimit.slidingWindow(limit, window),
|
|
113
|
+
prefix: `offline:read:${window}`,
|
|
114
|
+
});
|
|
115
|
+
const result = await limiter.limit(subject.key);
|
|
116
|
+
if (!result.success) {
|
|
117
|
+
return NextResponse.json(
|
|
118
|
+
{
|
|
119
|
+
message:
|
|
120
|
+
'Read limit reached. Try again after the indicated delay.',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
status: 429,
|
|
124
|
+
headers: {
|
|
125
|
+
'Cache-Control': 'no-store',
|
|
126
|
+
'Retry-After': `${Math.max(1, Math.ceil((result.reset - Date.now()) / 1000))}`,
|
|
127
|
+
'X-RateLimit-Policy': 'offline-capable-read',
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (!sessionKey)
|
|
135
|
+
return NextResponse.json(
|
|
136
|
+
{ message: 'Authentication required' },
|
|
137
|
+
{ status: 401 }
|
|
138
|
+
);
|
|
139
|
+
const trust = (await getCachedTrustEntries([sessionKey])).get(sessionKey);
|
|
140
|
+
// Reuse established account trust. This check does not write trust markers.
|
|
141
|
+
if (trust?.verified && (trust.m > 1 || trust.mode === 'unlimited'))
|
|
142
|
+
return null;
|
|
143
|
+
const clearanceKey = `offline:clearance:${sessionKey}`;
|
|
144
|
+
if (await redis.get(clearanceKey)) return null;
|
|
145
|
+
const token = request.headers.get('x-tuturuuu-turnstile-token');
|
|
146
|
+
if (!token) return challenge();
|
|
147
|
+
try {
|
|
148
|
+
await verifyTurnstileToken(request, token, {
|
|
149
|
+
remoteIp: ip === 'unknown' ? undefined : ip,
|
|
150
|
+
});
|
|
151
|
+
} catch {
|
|
152
|
+
return challenge();
|
|
153
|
+
}
|
|
154
|
+
await redis.set(clearanceKey, 'passed', { ex: CLEARANCE_SECONDS });
|
|
155
|
+
return null;
|
|
156
|
+
} catch {
|
|
157
|
+
// A distributed guard must not silently fall back to per-process limits.
|
|
158
|
+
return unavailable();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -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.66.0'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('preserves the source timestamp in builtAt 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.66.0'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
6
|
/** ISO timestamp of the source commit used for the build, not deployment time. */
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
PlaygroundDelta,
|
|
4
|
+
PlaygroundFiles,
|
|
5
|
+
PlaygroundJob,
|
|
6
|
+
PlaygroundRunnerExport,
|
|
7
|
+
} from './playground-schema';
|
|
8
|
+
|
|
9
|
+
it.each([
|
|
10
|
+
'../secret',
|
|
11
|
+
'a/../../secret',
|
|
12
|
+
'/absolute',
|
|
13
|
+
'node_modules/pkg.js',
|
|
14
|
+
'a/.git/config',
|
|
15
|
+
'a//b',
|
|
16
|
+
'a\\b',
|
|
17
|
+
])('rejects unsafe path %s', (path) =>
|
|
18
|
+
expect(PlaygroundFiles.safeParse([{ path, content: 'x' }]).success).toBe(
|
|
19
|
+
false
|
|
20
|
+
)
|
|
21
|
+
);
|
|
22
|
+
it('enforces UTF-8 byte limits rather than only character counts', () =>
|
|
23
|
+
expect(
|
|
24
|
+
PlaygroundFiles.safeParse([{ path: 'a', content: 'ấ'.repeat(100000) }])
|
|
25
|
+
.success
|
|
26
|
+
).toBe(false));
|
|
27
|
+
it('rejects inconsistent delta inventories', () => {
|
|
28
|
+
expect(
|
|
29
|
+
PlaygroundDelta.safeParse({
|
|
30
|
+
revision: 1,
|
|
31
|
+
command: 'run',
|
|
32
|
+
paths: ['a', 'a/b'],
|
|
33
|
+
files: [],
|
|
34
|
+
}).success
|
|
35
|
+
).toBe(false);
|
|
36
|
+
expect(
|
|
37
|
+
PlaygroundDelta.safeParse({
|
|
38
|
+
revision: 1,
|
|
39
|
+
command: 'run',
|
|
40
|
+
paths: ['a'],
|
|
41
|
+
files: [{ path: 'b', content: 'x' }],
|
|
42
|
+
}).success
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
it('bounds preview requests to approved ports and paths', () => {
|
|
46
|
+
const job = {
|
|
47
|
+
projectId: '00000000-0000-4000-8000-000000000001',
|
|
48
|
+
revision: 0,
|
|
49
|
+
language: 'python',
|
|
50
|
+
operation: 'preview',
|
|
51
|
+
port: 22,
|
|
52
|
+
path: '/',
|
|
53
|
+
};
|
|
54
|
+
expect(PlaygroundJob.safeParse(job).success).toBe(false);
|
|
55
|
+
expect(
|
|
56
|
+
PlaygroundJob.safeParse({ ...job, port: 3000, path: '/\r\nHost:metadata' })
|
|
57
|
+
.success
|
|
58
|
+
).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects file/directory collisions with intervening sorted siblings', () => {
|
|
62
|
+
const paths = ['a', 'a-b', 'a/b'];
|
|
63
|
+
expect(
|
|
64
|
+
PlaygroundFiles.safeParse(paths.map((path) => ({ path, content: 'x' })))
|
|
65
|
+
.success
|
|
66
|
+
).toBe(false);
|
|
67
|
+
expect(
|
|
68
|
+
PlaygroundDelta.safeParse({ revision: 1, command: 'run', paths, files: [] })
|
|
69
|
+
.success
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('rejects conflicting runner inventory ancestors', () => {
|
|
74
|
+
expect(
|
|
75
|
+
PlaygroundRunnerExport.safeParse({
|
|
76
|
+
revision: 1,
|
|
77
|
+
paths: ['a', 'a-b', 'a/b'],
|
|
78
|
+
files: [],
|
|
79
|
+
baseline: {},
|
|
80
|
+
}).success
|
|
81
|
+
).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it.each([
|
|
85
|
+
'/../secret',
|
|
86
|
+
'/a/./b',
|
|
87
|
+
'/a//b',
|
|
88
|
+
'//other-host',
|
|
89
|
+
'/%2e%2e/secret',
|
|
90
|
+
'/a%2f../secret',
|
|
91
|
+
'/a\\b',
|
|
92
|
+
'/%',
|
|
93
|
+
])('rejects unsafe preview path %s', (path) => {
|
|
94
|
+
expect(
|
|
95
|
+
PlaygroundJob.safeParse({
|
|
96
|
+
projectId: '00000000-0000-4000-8000-000000000001',
|
|
97
|
+
revision: 0,
|
|
98
|
+
language: 'python',
|
|
99
|
+
operation: 'preview',
|
|
100
|
+
port: 3000,
|
|
101
|
+
path,
|
|
102
|
+
}).success
|
|
103
|
+
).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
it.each(['/', '/index.html', '/assets/app.js?version=1'])(
|
|
106
|
+
'accepts ordinary preview path %s',
|
|
107
|
+
(path) => {
|
|
108
|
+
expect(
|
|
109
|
+
PlaygroundJob.safeParse({
|
|
110
|
+
projectId: '00000000-0000-4000-8000-000000000001',
|
|
111
|
+
revision: 0,
|
|
112
|
+
language: 'python',
|
|
113
|
+
operation: 'preview',
|
|
114
|
+
port: 3000,
|
|
115
|
+
path,
|
|
116
|
+
}).success
|
|
117
|
+
).toBe(true);
|
|
118
|
+
}
|
|
119
|
+
);
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { PLAYGROUND_LANGUAGES } from '@tuturuuu/types/primitives/playgrounds';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
function hasDirectoryConflict(paths: string[]) {
|
|
5
|
+
const inventory = new Set(paths);
|
|
6
|
+
return paths.some((path) => {
|
|
7
|
+
const pieces = path.split('/');
|
|
8
|
+
return pieces
|
|
9
|
+
.slice(0, -1)
|
|
10
|
+
.some((_, index) => inventory.has(pieces.slice(0, index + 1).join('/')));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function validPreviewPath(path: string) {
|
|
15
|
+
if (!path.startsWith('/') || /[\r\n\0\\]/.test(path)) return false;
|
|
16
|
+
try {
|
|
17
|
+
const pathname = decodeURIComponent(path.split(/[?#]/, 1)[0]!);
|
|
18
|
+
return (
|
|
19
|
+
pathname === '/' ||
|
|
20
|
+
(!/[\r\n\0\\]/.test(pathname) &&
|
|
21
|
+
pathname
|
|
22
|
+
.slice(1)
|
|
23
|
+
.split('/')
|
|
24
|
+
.every((part) => part !== '' && part !== '.' && part !== '..'))
|
|
25
|
+
);
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const PLAYGROUND_BYTES = 2 * 1024 * 1024;
|
|
32
|
+
export const PlaygroundPath = z
|
|
33
|
+
.string()
|
|
34
|
+
.min(1)
|
|
35
|
+
.max(240)
|
|
36
|
+
.refine(
|
|
37
|
+
(path) =>
|
|
38
|
+
path
|
|
39
|
+
.split('/')
|
|
40
|
+
.every(
|
|
41
|
+
(part) =>
|
|
42
|
+
/^[a-zA-Z0-9_.@+-]+$/.test(part) && part !== '.' && part !== '..'
|
|
43
|
+
) &&
|
|
44
|
+
!path
|
|
45
|
+
.split('/')
|
|
46
|
+
.some((part) =>
|
|
47
|
+
['node_modules', '.git', '__pycache__', 'target', '.venv'].includes(
|
|
48
|
+
part
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
export const PlaygroundFiles = z
|
|
53
|
+
.array(
|
|
54
|
+
z
|
|
55
|
+
.object({
|
|
56
|
+
path: PlaygroundPath,
|
|
57
|
+
content: z
|
|
58
|
+
.string()
|
|
59
|
+
.max(256 * 1024)
|
|
60
|
+
.refine(
|
|
61
|
+
(value) =>
|
|
62
|
+
!value.includes('\0') &&
|
|
63
|
+
new TextEncoder().encode(value).byteLength <= 256 * 1024
|
|
64
|
+
),
|
|
65
|
+
})
|
|
66
|
+
.strict()
|
|
67
|
+
)
|
|
68
|
+
.max(128)
|
|
69
|
+
.superRefine((files, ctx) => {
|
|
70
|
+
if (new Set(files.map((file) => file.path)).size !== files.length)
|
|
71
|
+
ctx.addIssue({ code: 'custom', message: 'Duplicate file paths' });
|
|
72
|
+
if (
|
|
73
|
+
files.reduce(
|
|
74
|
+
(size, file) => size + new TextEncoder().encode(file.content).length,
|
|
75
|
+
0
|
|
76
|
+
) > PLAYGROUND_BYTES
|
|
77
|
+
)
|
|
78
|
+
ctx.addIssue({
|
|
79
|
+
code: 'custom',
|
|
80
|
+
message: 'Project exceeds storage limit',
|
|
81
|
+
});
|
|
82
|
+
const paths = files.map((file) => file.path).sort();
|
|
83
|
+
if (hasDirectoryConflict(paths))
|
|
84
|
+
ctx.addIssue({
|
|
85
|
+
code: 'custom',
|
|
86
|
+
message: 'File conflicts with directory',
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
export const PlaygroundCreate = z
|
|
90
|
+
.object({
|
|
91
|
+
name: z.string().trim().min(1).max(80),
|
|
92
|
+
language: z.enum(PLAYGROUND_LANGUAGES),
|
|
93
|
+
})
|
|
94
|
+
.strict();
|
|
95
|
+
export const PlaygroundSave = z
|
|
96
|
+
.object({
|
|
97
|
+
revision: z.number().int().nonnegative(),
|
|
98
|
+
files: PlaygroundFiles,
|
|
99
|
+
command: z
|
|
100
|
+
.string()
|
|
101
|
+
.min(1)
|
|
102
|
+
.max(4096)
|
|
103
|
+
.refine((value) => !value.includes('\0')),
|
|
104
|
+
})
|
|
105
|
+
.strict();
|
|
106
|
+
export const PlaygroundJob = z
|
|
107
|
+
.object({
|
|
108
|
+
projectId: z.guid(),
|
|
109
|
+
revision: z.number().int().nonnegative(),
|
|
110
|
+
language: z.enum(PLAYGROUND_LANGUAGES),
|
|
111
|
+
operation: z.enum(['run', 'stop', 'preview']),
|
|
112
|
+
files: PlaygroundFiles.optional(),
|
|
113
|
+
command: z.string().max(4096).optional(),
|
|
114
|
+
stdin: z.string().max(65536).optional(),
|
|
115
|
+
port: z.number().int().min(1024).max(65535).optional(),
|
|
116
|
+
path: z.string().max(2048).optional(),
|
|
117
|
+
})
|
|
118
|
+
.strict()
|
|
119
|
+
.superRefine((job, ctx) => {
|
|
120
|
+
if (
|
|
121
|
+
job.operation === 'run' &&
|
|
122
|
+
(!job.files || !job.command || job.command.includes('\0'))
|
|
123
|
+
)
|
|
124
|
+
ctx.addIssue({ code: 'custom', message: 'Missing run payload' });
|
|
125
|
+
if (
|
|
126
|
+
job.operation === 'preview' &&
|
|
127
|
+
(!job.port || !job.path || !validPreviewPath(job.path))
|
|
128
|
+
)
|
|
129
|
+
ctx.addIssue({ code: 'custom', message: 'Invalid preview request' });
|
|
130
|
+
});
|
|
131
|
+
export type PlaygroundJobPayload = z.infer<typeof PlaygroundJob>;
|
|
132
|
+
|
|
133
|
+
/** Full path inventory plus changed text; unchanged file bytes never cross the callback. */
|
|
134
|
+
export const PlaygroundDelta = PlaygroundSave.extend({
|
|
135
|
+
paths: z.array(PlaygroundPath).max(128),
|
|
136
|
+
}).superRefine((value, ctx) => {
|
|
137
|
+
const paths = [...value.paths].sort();
|
|
138
|
+
if (
|
|
139
|
+
new Set(paths).size !== paths.length ||
|
|
140
|
+
hasDirectoryConflict(paths) ||
|
|
141
|
+
value.files.some((file) => !paths.includes(file.path))
|
|
142
|
+
)
|
|
143
|
+
ctx.addIssue({ code: 'custom', message: 'Invalid delta inventory' });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/** Authenticated runner export merged into the room before Drive persistence. */
|
|
147
|
+
export const PlaygroundRunnerExport = z
|
|
148
|
+
.object({
|
|
149
|
+
revision: z.number().int().nonnegative(),
|
|
150
|
+
files: PlaygroundFiles,
|
|
151
|
+
paths: z.array(PlaygroundPath).max(128),
|
|
152
|
+
baseline: z.record(PlaygroundPath, z.string().regex(/^[a-f0-9]{64}$/)),
|
|
153
|
+
})
|
|
154
|
+
.strict()
|
|
155
|
+
.refine(
|
|
156
|
+
(value) =>
|
|
157
|
+
new Set(value.paths).size === value.paths.length &&
|
|
158
|
+
!hasDirectoryConflict(value.paths) &&
|
|
159
|
+
value.files.every((file) => value.paths.includes(file.path)),
|
|
160
|
+
'Invalid runner inventory'
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
export const PlaygroundPreviewCapability = z
|
|
164
|
+
.object({
|
|
165
|
+
aud: z.literal('tuturuuu.playground-preview'),
|
|
166
|
+
ownerId: z.guid(),
|
|
167
|
+
projectId: z.guid(),
|
|
168
|
+
port: z.number().int().min(1024).max(65535),
|
|
169
|
+
meetingId: z.guid().optional(),
|
|
170
|
+
roomId: z.guid().optional(),
|
|
171
|
+
exp: z.number().int().positive(),
|
|
172
|
+
})
|
|
173
|
+
.strict();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { PlaygroundLanguage } from '@tuturuuu/types/primitives/playgrounds';
|
|
2
|
+
export const playgroundTemplates: Record<
|
|
3
|
+
PlaygroundLanguage,
|
|
4
|
+
{ path: string; content: string; command: string }
|
|
5
|
+
> = {
|
|
6
|
+
python: {
|
|
7
|
+
path: 'main.py',
|
|
8
|
+
content: 'print("Hello, playground!")\n',
|
|
9
|
+
command: 'python3 main.py',
|
|
10
|
+
},
|
|
11
|
+
javascript: {
|
|
12
|
+
path: 'main.js',
|
|
13
|
+
content: 'console.log("Hello, playground!");\n',
|
|
14
|
+
command: 'node main.js',
|
|
15
|
+
},
|
|
16
|
+
typescript: {
|
|
17
|
+
path: 'main.ts',
|
|
18
|
+
content: 'console.log("Hello, playground!");\n',
|
|
19
|
+
command: 'bun main.ts',
|
|
20
|
+
},
|
|
21
|
+
c: {
|
|
22
|
+
path: 'main.c',
|
|
23
|
+
content:
|
|
24
|
+
'#include <stdio.h>\nint main(void) { puts("Hello, playground!"); }\n',
|
|
25
|
+
command: 'gcc main.c -o /tmp/main && /tmp/main',
|
|
26
|
+
},
|
|
27
|
+
cpp: {
|
|
28
|
+
path: 'main.cpp',
|
|
29
|
+
content:
|
|
30
|
+
'#include <iostream>\nint main() { std::cout << "Hello, playground!\\n"; }\n',
|
|
31
|
+
command: 'g++ main.cpp -o /tmp/main && /tmp/main',
|
|
32
|
+
},
|
|
33
|
+
java: {
|
|
34
|
+
path: 'Main.java',
|
|
35
|
+
content:
|
|
36
|
+
'class Main { public static void main(String[] args) { System.out.println("Hello, playground!"); } }\n',
|
|
37
|
+
command: 'javac Main.java && java Main',
|
|
38
|
+
},
|
|
39
|
+
rust: {
|
|
40
|
+
path: 'main.rs',
|
|
41
|
+
content: 'fn main() { println!("Hello, playground!"); }\n',
|
|
42
|
+
command: 'rustc main.rs -o /tmp/main && /tmp/main',
|
|
43
|
+
},
|
|
44
|
+
go: {
|
|
45
|
+
path: 'main.go',
|
|
46
|
+
content:
|
|
47
|
+
'package main\nimport "fmt"\nfunc main() { fmt.Println("Hello, playground!") }\n',
|
|
48
|
+
command: 'go run main.go',
|
|
49
|
+
},
|
|
50
|
+
ruby: {
|
|
51
|
+
path: 'main.rb',
|
|
52
|
+
content: 'puts "Hello, playground!"\n',
|
|
53
|
+
command: 'ruby main.rb',
|
|
54
|
+
},
|
|
55
|
+
php: {
|
|
56
|
+
path: 'main.php',
|
|
57
|
+
content: '<?php echo "Hello, playground!\\n";\n',
|
|
58
|
+
command: 'php main.php',
|
|
59
|
+
},
|
|
60
|
+
shell: {
|
|
61
|
+
path: 'main.sh',
|
|
62
|
+
content: 'printf "Hello, playground!\\n"\n',
|
|
63
|
+
command: 'sh main.sh',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { patterns } = require('./release-note-policy.json');
|
|
2
|
+
const matchers = patterns.map((pattern) => new RegExp(pattern, 'iu'));
|
|
3
|
+
|
|
4
|
+
/** Omit known Git bookkeeping, never arbitrary product text containing merge. */
|
|
5
|
+
function isReleaseBookkeeping(subject) {
|
|
6
|
+
const description = subject
|
|
7
|
+
.replace(/^\w+(?:\([^)]*\))?!?:\s*/u, '')
|
|
8
|
+
.replace(/^\*\*[^*]+:\*\*\s*/u, '')
|
|
9
|
+
.replace(/\s*\(#\d+\)$/u, '')
|
|
10
|
+
.replace(
|
|
11
|
+
/\s*\(\[[^\]]+\]\(https:\/\/github\.com\/tutur3u\/platform\/(?:commit|issues|pull)\/[^)]+\)\)/gu,
|
|
12
|
+
''
|
|
13
|
+
)
|
|
14
|
+
.trim();
|
|
15
|
+
return matchers.some((pattern) => pattern.test(description));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function filterReleaseBookkeeping(markdown) {
|
|
19
|
+
return markdown
|
|
20
|
+
.split('\n')
|
|
21
|
+
.filter((line) => {
|
|
22
|
+
const bullet = /^\s*[-*]\s+(.+)$/u.exec(line);
|
|
23
|
+
return !bullet || !isReleaseBookkeeping(bullet[1]);
|
|
24
|
+
})
|
|
25
|
+
.join('\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { isReleaseBookkeeping, filterReleaseBookkeeping };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"patterns": [
|
|
3
|
+
"^merge (?:remote-tracking )?(?:branch(?:es)?|tag|commit) ['\"][^'\"\\n]+['\"](?: (?:and ['\"][^'\"\\n]+['\"]|of \\S+|into (?:['\"][^'\"\\n]+['\"]|\\S+)))*$",
|
|
4
|
+
"^merge pull request #\\d+ from \\S+$",
|
|
5
|
+
"^merge (?:current |latest )?(?:main|production|origin/(?:main|production|release-please--branches--\\S+))(?: (?:into|to) (?:main|production))?$",
|
|
6
|
+
"^(?:sync|synchronize) (?:current |latest )?(?:main|production)(?: (?:to|into|with|from) (?:main|production))$",
|
|
7
|
+
"^address review and integrate current main$"
|
|
8
|
+
]
|
|
9
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": [
|
|
3
|
+
"admin",
|
|
4
|
+
"administrator",
|
|
5
|
+
"anonymous",
|
|
6
|
+
"account",
|
|
7
|
+
"accounts",
|
|
8
|
+
"artist",
|
|
9
|
+
"artists",
|
|
10
|
+
"creator",
|
|
11
|
+
"creators",
|
|
12
|
+
"dashboard",
|
|
13
|
+
"default",
|
|
14
|
+
"everyone",
|
|
15
|
+
"example",
|
|
16
|
+
"guest",
|
|
17
|
+
"help",
|
|
18
|
+
"home",
|
|
19
|
+
"login",
|
|
20
|
+
"logout",
|
|
21
|
+
"moderator",
|
|
22
|
+
"moderation",
|
|
23
|
+
"official",
|
|
24
|
+
"owner",
|
|
25
|
+
"personal",
|
|
26
|
+
"privacy",
|
|
27
|
+
"profile",
|
|
28
|
+
"public",
|
|
29
|
+
"root",
|
|
30
|
+
"security",
|
|
31
|
+
"settings",
|
|
32
|
+
"staff",
|
|
33
|
+
"support",
|
|
34
|
+
"system",
|
|
35
|
+
"terms",
|
|
36
|
+
"test",
|
|
37
|
+
"testing",
|
|
38
|
+
"unknown",
|
|
39
|
+
"user",
|
|
40
|
+
"username",
|
|
41
|
+
"users",
|
|
42
|
+
"website",
|
|
43
|
+
"webmaster"
|
|
44
|
+
],
|
|
45
|
+
"brands": [
|
|
46
|
+
"adobe",
|
|
47
|
+
"amazon",
|
|
48
|
+
"android",
|
|
49
|
+
"apple",
|
|
50
|
+
"chatgpt",
|
|
51
|
+
"claude",
|
|
52
|
+
"cloudflare",
|
|
53
|
+
"discord",
|
|
54
|
+
"exocorpse",
|
|
55
|
+
"facebook",
|
|
56
|
+
"github",
|
|
57
|
+
"gmail",
|
|
58
|
+
"google",
|
|
59
|
+
"instagram",
|
|
60
|
+
"linkedin",
|
|
61
|
+
"meta",
|
|
62
|
+
"microsoft",
|
|
63
|
+
"netflix",
|
|
64
|
+
"nintendo",
|
|
65
|
+
"nvidia",
|
|
66
|
+
"openai",
|
|
67
|
+
"paypal",
|
|
68
|
+
"samsung",
|
|
69
|
+
"slack",
|
|
70
|
+
"snapchat",
|
|
71
|
+
"sony",
|
|
72
|
+
"spotify",
|
|
73
|
+
"stripe",
|
|
74
|
+
"telegram",
|
|
75
|
+
"tesla",
|
|
76
|
+
"tiktok",
|
|
77
|
+
"tulletin",
|
|
78
|
+
"tulettin",
|
|
79
|
+
"tuturuuu",
|
|
80
|
+
"twitch",
|
|
81
|
+
"twitter",
|
|
82
|
+
"vercel",
|
|
83
|
+
"whatsapp",
|
|
84
|
+
"windows",
|
|
85
|
+
"youtube"
|
|
86
|
+
]
|
|
87
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import reserved from './username-policy.json';
|
|
2
|
+
|
|
3
|
+
export const USERNAME_MIN_LENGTH = 5;
|
|
4
|
+
export const USERNAME_MAX_LENGTH = 32;
|
|
5
|
+
export const USERNAME_CHANGE_INTERVAL_DAYS = 14;
|
|
6
|
+
export const DISPLAY_NAME_CHANGE_LIMIT = 2;
|
|
7
|
+
export const DISPLAY_NAME_CHANGE_WINDOW_DAYS = 7;
|
|
8
|
+
const reservedPatterns = [...reserved.common, ...reserved.brands].map(
|
|
9
|
+
(brand) =>
|
|
10
|
+
new RegExp(`^(official)?${brand}(official|support|admin|team|[0-9]+)?$`)
|
|
11
|
+
);
|
|
12
|
+
export function isReservedUsername(value: string) {
|
|
13
|
+
const normalized = value.toLowerCase().replaceAll('_', '');
|
|
14
|
+
return reservedPatterns.some((pattern) => pattern.test(normalized));
|
|
15
|
+
}
|
|
16
|
+
export function isValidNewUsername(value: string) {
|
|
17
|
+
return /^[a-z0-9][a-z0-9_]{4,31}$/.test(value) && !isReservedUsername(value);
|
|
18
|
+
}
|