@ismaelsoilet/synapsejs 1.8.0 → 1.11.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/README.md +98 -6
- package/bin/synapse.ts +291 -241
- package/package.json +2 -2
- package/src/client/components.tsx +459 -1
- package/src/client/context.tsx +23 -3
- package/src/client/error-boundary.tsx +59 -0
- package/src/client/hooks.ts +100 -1
- package/src/client/toast.tsx +219 -0
- package/src/client/upload.ts +153 -0
- package/src/client.ts +37 -2
- package/src/compiler/client-bundler.ts +94 -2
- package/src/compiler/context-bench.ts +9 -1
- package/src/compiler/contract.ts +31 -4
- package/src/compiler/db-schema-generator.ts +39 -8
- package/src/compiler/fields-parser.ts +28 -1
- package/src/compiler/impact-analyzer.ts +31 -1
- package/src/compiler/migration-runner.ts +10 -2
- package/src/compiler/optional-coverage.ts +137 -0
- package/src/compiler/oracle-runner.ts +18 -4
- package/src/compiler/scaffolder.ts +227 -9
- package/src/compiler/slice-discovery.ts +8 -1
- package/src/compiler/slice-splitter.ts +68 -6
- package/src/compiler/slice-templates.ts +234 -10
- package/src/compiler/typescript-api.ts +33 -0
- package/src/core/action-context.ts +101 -15
- package/src/core/config.ts +88 -10
- package/src/core/cron-parser.ts +167 -0
- package/src/core/database-client.ts +103 -6
- package/src/core/index.ts +5 -0
- package/src/core/jobs.ts +37 -0
- package/src/core/oauth-client.ts +322 -0
- package/src/core/path-guard.ts +105 -0
- package/src/core/postgres-client.ts +37 -0
- package/src/core/postgres-queue.ts +76 -9
- package/src/core/query-builder.ts +166 -34
- package/src/core/session-context.ts +45 -4
- package/src/core/session-token.ts +124 -8
- package/src/core/sqlite-client.ts +20 -0
- package/src/core/storage.ts +60 -19
- package/src/core/topics.ts +84 -0
- package/src/core/webhook-signature.ts +151 -0
- package/src/index.ts +2 -1
- package/src/mcp/server.ts +317 -218
- package/src/mcp/tools.ts +329 -0
- package/src/project-status.ts +433 -0
- package/src/runtime/client-entry.ts +11 -5
- package/src/runtime/network-guard.ts +3 -1
- package/src/runtime/queue-engine.ts +167 -5
- package/src/runtime/rate-limiter.ts +14 -4
- package/src/runtime/security-headers.ts +35 -0
- package/src/runtime/server.ts +1174 -236
- package/src/runtime/uploads.ts +199 -19
- package/src/version.ts +9 -2
- package/templates/starter/package.json +2 -2
- package/templates/starter/tsconfig.json +1 -0
package/src/runtime/uploads.ts
CHANGED
|
@@ -3,24 +3,46 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The RPC endpoint speaks JSON on purpose — that is what closes CSRF by
|
|
5
5
|
* construction — so files need their own door. This one requires a session,
|
|
6
|
-
* refuses a name that tries to escape the upload directory,
|
|
7
|
-
* moment the body passes the limit instead of buffering it first
|
|
6
|
+
* refuses a name that tries to escape the upload directory, stops reading the
|
|
7
|
+
* moment the body passes the limit instead of buffering it first, and never
|
|
8
|
+
* accepts a file type on the client's declared extension alone.
|
|
8
9
|
*
|
|
9
10
|
* The endpoint stores bytes and returns a relative path; what those bytes mean is
|
|
10
|
-
* the slice's business, decided by its own contract.
|
|
11
|
+
* the slice's business, decided by its own contract. The framework does not serve
|
|
12
|
+
* the upload directory — serving it is the application's responsibility.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import * as fs from 'fs';
|
|
14
16
|
import * as path from 'path';
|
|
15
17
|
import { Err, Ok, type Result } from '../core/machine-types';
|
|
18
|
+
import { validatePathSegment } from '../core/path-guard';
|
|
16
19
|
import type { SessionContext } from '../core/session-context';
|
|
17
20
|
|
|
18
21
|
export const DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
19
22
|
|
|
20
|
-
/**
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
/** Accepted by default; a configured policy replaces this list entirely. */
|
|
24
|
+
export const DEFAULT_ALLOWED_UPLOAD_EXTENSIONS = [
|
|
25
|
+
'png',
|
|
26
|
+
'jpg',
|
|
27
|
+
'jpeg',
|
|
28
|
+
'gif',
|
|
29
|
+
'webp',
|
|
30
|
+
'pdf',
|
|
31
|
+
'txt',
|
|
32
|
+
'csv',
|
|
33
|
+
'json',
|
|
34
|
+
'md'
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export type UploadErrorCode =
|
|
38
|
+
| 'INVALID_NAME'
|
|
39
|
+
| 'UNAUTHORIZED'
|
|
40
|
+
| 'TOO_LARGE'
|
|
41
|
+
| 'EMPTY_BODY'
|
|
42
|
+
| 'WRITE_FAILED'
|
|
43
|
+
| 'UNSUPPORTED_FILE_TYPE'
|
|
44
|
+
| 'INVALID_MULTIPART_BODY'
|
|
45
|
+
| 'NO_FILE_IN_FORM_DATA';
|
|
24
46
|
|
|
25
47
|
export function maxUploadBytes(raw: string | undefined = process.env.SYNAPSE_MAX_UPLOAD_BYTES): number {
|
|
26
48
|
const parsed = Number(raw);
|
|
@@ -28,22 +50,104 @@ export function maxUploadBytes(raw: string | undefined = process.env.SYNAPSE_MAX
|
|
|
28
50
|
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_MAX_UPLOAD_BYTES;
|
|
29
51
|
}
|
|
30
52
|
|
|
53
|
+
/** Reads SYNAPSE_UPLOAD_ALLOWED_TYPES (comma separated extensions) or the default policy. */
|
|
54
|
+
export function allowedUploadExtensions(raw: string | undefined = process.env.SYNAPSE_UPLOAD_ALLOWED_TYPES): string[] {
|
|
55
|
+
const configured = (raw ?? '')
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((entry) => entry.trim().toLowerCase().replace(/^\./, ''))
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
|
|
60
|
+
return configured.length > 0 ? configured : [...DEFAULT_ALLOWED_UPLOAD_EXTENSIONS];
|
|
61
|
+
}
|
|
62
|
+
|
|
31
63
|
/**
|
|
32
64
|
* Accepts a plain file name or refuses it — it never silently rewrites one, so a
|
|
33
65
|
* caller sending `../../etc/passwd` gets an error instead of a surprise file.
|
|
66
|
+
* The path-segment rule itself lives in core/path-guard, shared with every other
|
|
67
|
+
* write surface.
|
|
34
68
|
*/
|
|
35
69
|
export function safeUploadName(raw: string | null): Result<string, 'INVALID_NAME'> {
|
|
36
|
-
|
|
37
|
-
|
|
70
|
+
const validated = validatePathSegment(raw);
|
|
71
|
+
|
|
72
|
+
return validated.ok ? Ok(validated.value) : Err('INVALID_NAME');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type DetectedKind = 'png' | 'jpeg' | 'gif' | 'webp' | 'pdf' | 'text' | 'binary';
|
|
76
|
+
|
|
77
|
+
/** The extensions each detected content kind is allowed to be stored under. */
|
|
78
|
+
const KIND_EXTENSIONS: Record<DetectedKind, string[]> = {
|
|
79
|
+
png: ['png'],
|
|
80
|
+
jpeg: ['jpg', 'jpeg'],
|
|
81
|
+
gif: ['gif'],
|
|
82
|
+
webp: ['webp'],
|
|
83
|
+
pdf: ['pdf'],
|
|
84
|
+
text: ['txt', 'csv', 'json', 'md'],
|
|
85
|
+
binary: []
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function asciiAt(bytes: Uint8Array, start: number, end: number): string {
|
|
89
|
+
let out = '';
|
|
90
|
+
|
|
91
|
+
for (let index = start; index < end && index < bytes.length; index++) {
|
|
92
|
+
out += String.fromCharCode(bytes[index]);
|
|
38
93
|
}
|
|
39
94
|
|
|
40
|
-
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
41
97
|
|
|
42
|
-
|
|
43
|
-
|
|
98
|
+
/** Inspects magic bytes; anything unrecognised is text unless it contains NULs. */
|
|
99
|
+
export function detectFileKind(bytes: Uint8Array): DetectedKind {
|
|
100
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && asciiAt(bytes, 1, 4) === 'PNG') {
|
|
101
|
+
return 'png';
|
|
102
|
+
}
|
|
103
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
104
|
+
return 'jpeg';
|
|
105
|
+
}
|
|
106
|
+
if (bytes.length >= 6 && (asciiAt(bytes, 0, 6) === 'GIF87a' || asciiAt(bytes, 0, 6) === 'GIF89a')) {
|
|
107
|
+
return 'gif';
|
|
108
|
+
}
|
|
109
|
+
if (bytes.length >= 12 && asciiAt(bytes, 0, 4) === 'RIFF' && asciiAt(bytes, 8, 12) === 'WEBP') {
|
|
110
|
+
return 'webp';
|
|
111
|
+
}
|
|
112
|
+
if (bytes.length >= 5 && asciiAt(bytes, 0, 5) === '%PDF-') {
|
|
113
|
+
return 'pdf';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const window = bytes.subarray(0, Math.min(bytes.length, 8192));
|
|
117
|
+
|
|
118
|
+
return window.includes(0) ? 'binary' : 'text';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The lowercased extension a name carries, or an empty string. */
|
|
122
|
+
export function uploadExtension(name: string): string {
|
|
123
|
+
const dot = name.lastIndexOf('.');
|
|
124
|
+
|
|
125
|
+
if (dot <= 0 || dot === name.length - 1) {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return name.slice(dot + 1).toLowerCase();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The accept policy: the declared extension must be within the policy *and*
|
|
134
|
+
* consistent with the bytes actually received, so a client cannot choose the
|
|
135
|
+
* extension a file is served under.
|
|
136
|
+
*/
|
|
137
|
+
export function acceptUploadType(
|
|
138
|
+
name: string,
|
|
139
|
+
bytes: Uint8Array,
|
|
140
|
+
allowed: string[] = allowedUploadExtensions()
|
|
141
|
+
): Result<string, 'UNSUPPORTED_FILE_TYPE'> {
|
|
142
|
+
const extension = uploadExtension(name);
|
|
143
|
+
|
|
144
|
+
if (!extension || !allowed.includes(extension)) {
|
|
145
|
+
return Err('UNSUPPORTED_FILE_TYPE');
|
|
44
146
|
}
|
|
45
147
|
|
|
46
|
-
|
|
148
|
+
const kind = detectFileKind(bytes);
|
|
149
|
+
|
|
150
|
+
return KIND_EXTENSIONS[kind].includes(extension) ? Ok(extension) : Err('UNSUPPORTED_FILE_TYPE');
|
|
47
151
|
}
|
|
48
152
|
|
|
49
153
|
export interface UploadOutcome {
|
|
@@ -96,6 +200,46 @@ export async function readBodyWithinLimit(
|
|
|
96
200
|
return Ok(merged);
|
|
97
201
|
}
|
|
98
202
|
|
|
203
|
+
/** Thrown inside the guarded stream so a consumer can tell an oversize body apart. */
|
|
204
|
+
export class BodyTooLargeError extends Error {
|
|
205
|
+
constructor() {
|
|
206
|
+
super('BODY_TOO_LARGE');
|
|
207
|
+
this.name = 'BodyTooLargeError';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The streaming half of the size defence: wraps a body so it errors the moment more
|
|
213
|
+
* than `maxBytes` arrive. The consumer (Bun's form parser) is therefore never handed
|
|
214
|
+
* an unbounded body, and the whole body is never buffered to discover the violation.
|
|
215
|
+
*/
|
|
216
|
+
export function boundBodyStream(body: ReadableStream<Uint8Array> | null, maxBytes: number): ReadableStream<Uint8Array> {
|
|
217
|
+
if (!body) {
|
|
218
|
+
return new ReadableStream<Uint8Array>({
|
|
219
|
+
start(controller) {
|
|
220
|
+
controller.close();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let total = 0;
|
|
226
|
+
|
|
227
|
+
return body.pipeThrough(
|
|
228
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
229
|
+
transform(chunk, controller) {
|
|
230
|
+
total += chunk.byteLength;
|
|
231
|
+
|
|
232
|
+
if (total > maxBytes) {
|
|
233
|
+
controller.error(new BodyTooLargeError());
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
controller.enqueue(chunk);
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
99
243
|
export async function saveUpload(
|
|
100
244
|
request: Request,
|
|
101
245
|
options: { baseDir: string; domain: string; session: SessionContext; maxBytes?: number }
|
|
@@ -106,7 +250,10 @@ export async function saveUpload(
|
|
|
106
250
|
|
|
107
251
|
const maxBytes = options.maxBytes ?? maxUploadBytes();
|
|
108
252
|
|
|
109
|
-
|
|
253
|
+
// An absent or unparsable content-length means "unknown", not zero: the declared
|
|
254
|
+
// pre-check is skipped and the streaming guard below is what bounds the body.
|
|
255
|
+
const declaredHeader = request.headers.get('content-length');
|
|
256
|
+
const declared = declaredHeader ? parseInt(declaredHeader, 10) : NaN;
|
|
110
257
|
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
111
258
|
return { status: 413, body: { ok: false, error: 'TOO_LARGE', maxBytes } };
|
|
112
259
|
}
|
|
@@ -118,8 +265,15 @@ export async function saveUpload(
|
|
|
118
265
|
if (contentType.includes('multipart/form-data')) {
|
|
119
266
|
let formData: FormData;
|
|
120
267
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
268
|
+
// The form parser receives a stream that errors at the ceiling, so an oversized
|
|
269
|
+
// multipart body is refused mid-transfer instead of being buffered whole.
|
|
270
|
+
formData = await new Response(boundBodyStream(request.body, maxBytes), {
|
|
271
|
+
headers: { 'content-type': contentType }
|
|
272
|
+
}).formData();
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (err instanceof BodyTooLargeError) {
|
|
275
|
+
return { status: 413, body: { ok: false, error: 'TOO_LARGE', maxBytes } };
|
|
276
|
+
}
|
|
123
277
|
return { status: 400, body: { ok: false, error: 'INVALID_MULTIPART_BODY' } };
|
|
124
278
|
}
|
|
125
279
|
const file = formData.get('file');
|
|
@@ -145,8 +299,22 @@ export async function saveUpload(
|
|
|
145
299
|
body: { ok: false, error: 'INVALID_NAME', hint: 'nome do arquivo inválido' }
|
|
146
300
|
};
|
|
147
301
|
}
|
|
148
|
-
|
|
302
|
+
|
|
149
303
|
fileBytes = new Uint8Array(await uploadedFile.arrayBuffer());
|
|
304
|
+
|
|
305
|
+
const accepted = acceptUploadType(name.value, fileBytes);
|
|
306
|
+
if (!accepted.ok) {
|
|
307
|
+
return {
|
|
308
|
+
status: 415,
|
|
309
|
+
body: {
|
|
310
|
+
ok: false,
|
|
311
|
+
error: 'UNSUPPORTED_FILE_TYPE',
|
|
312
|
+
hint: `tipos aceitos: ${allowedUploadExtensions().join(', ')}`
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
targetFileName = name.value;
|
|
150
318
|
} else {
|
|
151
319
|
const name = safeUploadName(new URL(request.url).searchParams.get('name'));
|
|
152
320
|
|
|
@@ -166,6 +334,18 @@ export async function saveUpload(
|
|
|
166
334
|
return { status: 422, body: { ok: false, error: 'EMPTY_BODY' } };
|
|
167
335
|
}
|
|
168
336
|
|
|
337
|
+
const accepted = acceptUploadType(name.value, body.value);
|
|
338
|
+
if (!accepted.ok) {
|
|
339
|
+
return {
|
|
340
|
+
status: 415,
|
|
341
|
+
body: {
|
|
342
|
+
ok: false,
|
|
343
|
+
error: 'UNSUPPORTED_FILE_TYPE',
|
|
344
|
+
hint: `tipos aceitos: ${allowedUploadExtensions().join(', ')}`
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
169
349
|
targetFileName = name.value;
|
|
170
350
|
fileBytes = body.value;
|
|
171
351
|
}
|
|
@@ -177,8 +357,8 @@ export async function saveUpload(
|
|
|
177
357
|
try {
|
|
178
358
|
fs.mkdirSync(directory, { recursive: true });
|
|
179
359
|
fs.writeFileSync(target, fileBytes);
|
|
180
|
-
} catch
|
|
181
|
-
return { status: 500, body: { ok: false, error: 'WRITE_FAILED'
|
|
360
|
+
} catch {
|
|
361
|
+
return { status: 500, body: { ok: false, error: 'WRITE_FAILED' } };
|
|
182
362
|
}
|
|
183
363
|
|
|
184
364
|
return {
|
package/src/version.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SynapseJS framework version
|
|
2
|
+
* SynapseJS framework version.
|
|
3
|
+
*
|
|
4
|
+
* The version is declared exactly once — in the published package manifest — and
|
|
5
|
+
* every machine-readable surface (health, MCP handshake, `synapse info`, the build
|
|
6
|
+
* output) reads it from here. No surface is permitted to carry a literal of its own.
|
|
3
7
|
*/
|
|
4
|
-
|
|
8
|
+
|
|
9
|
+
import manifest from '../package.json';
|
|
10
|
+
|
|
11
|
+
export const SYNAPSE_VERSION: string = (manifest as { version: string }).version;
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"start": "synapse start"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ismaelsoilet/synapsejs": "^1.
|
|
23
|
+
"@ismaelsoilet/synapsejs": "^1.11.1",
|
|
24
24
|
"@sinclair/typebox": "^0.34.40",
|
|
25
25
|
"fast-check": "^3.23.2",
|
|
26
26
|
"react": "^19.0.0",
|
|
@@ -30,6 +30,6 @@
|
|
|
30
30
|
"@types/bun": "^1.4.2",
|
|
31
31
|
"@types/react": "^19.0.10",
|
|
32
32
|
"@types/react-dom": "^19.0.4",
|
|
33
|
-
"typescript": "
|
|
33
|
+
"typescript": "~5.9.3"
|
|
34
34
|
}
|
|
35
35
|
}
|