@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.
Files changed (55) hide show
  1. package/README.md +98 -6
  2. package/bin/synapse.ts +291 -241
  3. package/package.json +2 -2
  4. package/src/client/components.tsx +459 -1
  5. package/src/client/context.tsx +23 -3
  6. package/src/client/error-boundary.tsx +59 -0
  7. package/src/client/hooks.ts +100 -1
  8. package/src/client/toast.tsx +219 -0
  9. package/src/client/upload.ts +153 -0
  10. package/src/client.ts +37 -2
  11. package/src/compiler/client-bundler.ts +94 -2
  12. package/src/compiler/context-bench.ts +9 -1
  13. package/src/compiler/contract.ts +31 -4
  14. package/src/compiler/db-schema-generator.ts +39 -8
  15. package/src/compiler/fields-parser.ts +28 -1
  16. package/src/compiler/impact-analyzer.ts +31 -1
  17. package/src/compiler/migration-runner.ts +10 -2
  18. package/src/compiler/optional-coverage.ts +137 -0
  19. package/src/compiler/oracle-runner.ts +18 -4
  20. package/src/compiler/scaffolder.ts +227 -9
  21. package/src/compiler/slice-discovery.ts +8 -1
  22. package/src/compiler/slice-splitter.ts +68 -6
  23. package/src/compiler/slice-templates.ts +234 -10
  24. package/src/compiler/typescript-api.ts +33 -0
  25. package/src/core/action-context.ts +101 -15
  26. package/src/core/config.ts +88 -10
  27. package/src/core/cron-parser.ts +167 -0
  28. package/src/core/database-client.ts +103 -6
  29. package/src/core/index.ts +5 -0
  30. package/src/core/jobs.ts +37 -0
  31. package/src/core/oauth-client.ts +322 -0
  32. package/src/core/path-guard.ts +105 -0
  33. package/src/core/postgres-client.ts +37 -0
  34. package/src/core/postgres-queue.ts +76 -9
  35. package/src/core/query-builder.ts +166 -34
  36. package/src/core/session-context.ts +45 -4
  37. package/src/core/session-token.ts +124 -8
  38. package/src/core/sqlite-client.ts +20 -0
  39. package/src/core/storage.ts +60 -19
  40. package/src/core/topics.ts +84 -0
  41. package/src/core/webhook-signature.ts +151 -0
  42. package/src/index.ts +2 -1
  43. package/src/mcp/server.ts +317 -218
  44. package/src/mcp/tools.ts +329 -0
  45. package/src/project-status.ts +433 -0
  46. package/src/runtime/client-entry.ts +11 -5
  47. package/src/runtime/network-guard.ts +3 -1
  48. package/src/runtime/queue-engine.ts +167 -5
  49. package/src/runtime/rate-limiter.ts +14 -4
  50. package/src/runtime/security-headers.ts +35 -0
  51. package/src/runtime/server.ts +1174 -236
  52. package/src/runtime/uploads.ts +199 -19
  53. package/src/version.ts +9 -2
  54. package/templates/starter/package.json +2 -2
  55. package/templates/starter/tsconfig.json +1 -0
@@ -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, and stops reading the
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
- /** No separators, no `..`, no leading dash or dot, and a sane length. */
21
- const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
22
-
23
- export type UploadErrorCode = 'INVALID_NAME' | 'UNAUTHORIZED' | 'TOO_LARGE' | 'EMPTY_BODY' | 'WRITE_FAILED';
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
- if (!raw) {
37
- return Err('INVALID_NAME');
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
- const trimmed = raw.trim();
95
+ return out;
96
+ }
41
97
 
42
- if (path.basename(trimmed) !== trimmed || !NAME_PATTERN.test(trimmed)) {
43
- return Err('INVALID_NAME');
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
- return Ok(trimmed);
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
- const declared = Number(request.headers.get('content-length') ?? '');
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
- formData = await request.formData();
122
- } catch {
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
- targetFileName = name.value;
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 (err) {
181
- return { status: 500, body: { ok: false, error: 'WRITE_FAILED', message: (err as Error).message } };
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 constant.
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
- export const SYNAPSE_VERSION = '1.8.0';
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.1.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": "^5.8.2"
33
+ "typescript": "~5.9.3"
34
34
  }
35
35
  }
@@ -3,6 +3,7 @@
3
3
  "target": "ESNext",
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
+ "resolveJsonModule": true,
6
7
  "jsx": "react-jsx",
7
8
  "strict": true,
8
9
  "skipLibCheck": true,